From dde89d2a834af2ccf9429d5ee9cbcaeee18a559d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 16 Mar 2026 21:47:21 -0700 Subject: [PATCH] refactor: isolate provider sdk auth and model helpers --- extensions/whatsapp/src/channel.ts | 4 - src/channels/plugins/setup-wizard-helpers.ts | 8 +- src/commands/auth-choice.api-key.ts | 53 +- src/commands/auth-choice.apply-helpers.ts | 464 +--------------- .../auth-choice.apply.plugin-provider.ts | 8 +- src/commands/auth-token.ts | 46 +- src/commands/google-gemini-model-default.ts | 15 +- src/commands/model-allowlist.ts | 42 +- src/commands/model-default.ts | 49 +- src/commands/model-picker.ts | 30 +- src/commands/oauth-flow.ts | 54 +- src/commands/oauth-tls-preflight.ts | 165 +----- .../local/auth-choice.plugin-providers.ts | 3 +- src/commands/onboard-types.ts | 3 +- src/commands/openai-codex-oauth.ts | 66 +-- src/commands/openai-model-default.ts | 52 +- src/commands/opencode-go-model-default.ts | 15 +- src/commands/opencode-zen-model-default.ts | 23 +- src/commands/self-hosted-provider-setup.ts | 12 +- src/plugin-sdk/provider-auth.ts | 11 +- src/plugin-sdk/provider-models.ts | 8 +- src/plugin-sdk/provider-onboard.ts | 2 +- src/plugins/provider-api-key-auth.runtime.ts | 9 +- src/plugins/provider-auth-helpers.ts | 2 +- src/plugins/provider-auth-input.ts | 496 ++++++++++++++++++ src/plugins/provider-auth-token.ts | 38 ++ src/plugins/provider-auth-types.ts | 1 + src/plugins/provider-model-allowlist.ts | 41 ++ src/plugins/provider-model-defaults.ts | 81 +++ src/plugins/provider-model-primary.ts | 72 +++ src/plugins/provider-oauth-flow.ts | 53 ++ .../provider-openai-codex-oauth-tls.ts | 164 ++++++ src/plugins/provider-openai-codex-oauth.ts | 65 +++ src/plugins/types.ts | 17 +- src/wizard/setup.gateway-config.ts | 8 +- 35 files changed, 1118 insertions(+), 1062 deletions(-) create mode 100644 src/plugins/provider-auth-input.ts create mode 100644 src/plugins/provider-auth-token.ts create mode 100644 src/plugins/provider-auth-types.ts create mode 100644 src/plugins/provider-model-allowlist.ts create mode 100644 src/plugins/provider-model-defaults.ts create mode 100644 src/plugins/provider-model-primary.ts create mode 100644 src/plugins/provider-oauth-flow.ts create mode 100644 src/plugins/provider-openai-codex-oauth-tls.ts create mode 100644 src/plugins/provider-openai-codex-oauth.ts diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 63d222ba1ed..dda6215c27f 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -41,10 +41,6 @@ import { collectWhatsAppStatusIssues } from "./status-issues.js"; const meta = getChatChannelMeta("whatsapp"); -async function loadWhatsAppChannelRuntime() { - return await import("./channel.runtime.js"); -} - function normalizeWhatsAppPayloadText(text: string | undefined): string { return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); } diff --git a/src/channels/plugins/setup-wizard-helpers.ts b/src/channels/plugins/setup-wizard-helpers.ts index de513f64d27..c80a00dd324 100644 --- a/src/channels/plugins/setup-wizard-helpers.ts +++ b/src/channels/plugins/setup-wizard-helpers.ts @@ -1,10 +1,10 @@ -import { - promptSecretRefForSetup, - resolveSecretInputModeForEnvSelection, -} from "../../commands/auth-choice.apply-helpers.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { SecretInput } from "../../config/types.secrets.js"; +import { + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../../plugins/provider-auth-input.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { diff --git a/src/commands/auth-choice.api-key.ts b/src/commands/auth-choice.api-key.ts index 59a7ca08e6f..ae5e716a46f 100644 --- a/src/commands/auth-choice.api-key.ts +++ b/src/commands/auth-choice.api-key.ts @@ -1,48 +1,5 @@ -const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; - -export function normalizeApiKeyInput(raw: string): string { - const trimmed = String(raw ?? "").trim(); - if (!trimmed) { - return ""; - } - - // Handle shell-style assignments: export KEY="value" or KEY=value - const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/); - const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; - - const unquoted = - valuePart.length >= 2 && - ((valuePart.startsWith('"') && valuePart.endsWith('"')) || - (valuePart.startsWith("'") && valuePart.endsWith("'")) || - (valuePart.startsWith("`") && valuePart.endsWith("`"))) - ? valuePart.slice(1, -1) - : valuePart; - - const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted; - - return withoutSemicolon.trim(); -} - -export const validateApiKeyInput = (value: string) => - normalizeApiKeyInput(value).length > 0 ? undefined : "Required"; - -export function formatApiKeyPreview( - raw: string, - opts: { head?: number; tail?: number } = {}, -): string { - const trimmed = raw.trim(); - if (!trimmed) { - return "…"; - } - const head = opts.head ?? DEFAULT_KEY_PREVIEW.head; - const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail; - if (trimmed.length <= head + tail) { - const shortHead = Math.min(2, trimmed.length); - const shortTail = Math.min(2, trimmed.length - shortHead); - if (shortTail <= 0) { - return `${trimmed.slice(0, shortHead)}…`; - } - return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; - } - return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; -} +export { + formatApiKeyPreview, + normalizeApiKeyInput, + validateApiKeyInput, +} from "../plugins/provider-auth-input.js"; diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index 7029dd081c3..b123f50f99c 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -1,280 +1,19 @@ -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import type { OpenClawConfig } from "../config/types.js"; -import { - isValidEnvSecretRefId, - type SecretInput, - type SecretRef, -} from "../config/types.secrets.js"; -import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; -import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; -import { - formatExecSecretRefIdValidationMessage, - isValidExecSecretRefId, - isValidFileSecretRefId, - resolveDefaultSecretProviderAlias, -} from "../secrets/ref-contract.js"; -import { resolveSecretRefString } from "../secrets/resolve.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { formatApiKeyPreview } from "./auth-choice.api-key.js"; import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js"; import { applyDefaultModelChoice } from "./auth-choice.default-model.js"; -import type { SecretInputMode } from "./onboard-types.js"; -const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; - -type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret - -export type SecretInputModePromptCopy = { - modeMessage?: string; - plaintextLabel?: string; - plaintextHint?: string; - refLabel?: string; - refHint?: string; -}; - -export type SecretRefSetupPromptCopy = { - sourceMessage?: string; - envVarMessage?: string; - envVarPlaceholder?: string; - envVarFormatError?: string; - envVarMissingError?: (envVar: string) => string; - noProvidersMessage?: string; - envValidatedMessage?: (envVar: string) => string; - providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string; -}; - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === "string" && error.message.trim()) { - return error.message; - } - return String(error); -} - -function extractEnvVarFromSourceLabel(source: string): string | undefined { - const match = ENV_SOURCE_LABEL_RE.exec(source.trim()); - return match?.[1]; -} - -function resolveDefaultProviderEnvVar(provider: string): string | undefined { - const envVars = PROVIDER_ENV_VARS[provider]; - return envVars?.find((candidate) => candidate.trim().length > 0); -} - -function resolveDefaultFilePointerId(provider: string): string { - return `/providers/${encodeJsonPointerToken(provider)}/apiKey`; -} - -function resolveRefFallbackInput(params: { - config: OpenClawConfig; - provider: string; - preferredEnvVar?: string; -}): { ref: SecretRef; resolvedValue: string } { - const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); - if (!fallbackEnvVar) { - throw new Error( - `No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`, - ); - } - const value = process.env[fallbackEnvVar]?.trim(); - if (!value) { - throw new Error( - `Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`, - ); - } - return { - ref: { - source: "env", - provider: resolveDefaultSecretProviderAlias(params.config, "env", { - preferFirstProviderForSource: true, - }), - id: fallbackEnvVar, - }, - resolvedValue: value, - }; -} - -export async function promptSecretRefForSetup(params: { - provider: string; - config: OpenClawConfig; - prompter: WizardPrompter; - preferredEnvVar?: string; - copy?: SecretRefSetupPromptCopy; -}): Promise<{ ref: SecretRef; resolvedValue: string }> { - const defaultEnvVar = - params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; - const defaultFilePointer = resolveDefaultFilePointerId(params.provider); - let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret - - while (true) { - const sourceRaw: SecretRefChoice = await params.prompter.select({ - message: params.copy?.sourceMessage ?? "Where is this API key stored?", - initialValue: sourceChoice, - options: [ - { - value: "env", - label: "Environment variable", - hint: "Reference a variable from your runtime environment", - }, - { - value: "provider", - label: "Configured secret provider", - hint: "Use a configured file or exec secret provider", - }, - ], - }); - const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env"; - sourceChoice = source; - - if (source === "env") { - const envVarRaw = await params.prompter.text({ - message: params.copy?.envVarMessage ?? "Environment variable name", - initialValue: defaultEnvVar || undefined, - placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY", - validate: (value) => { - const candidate = value.trim(); - if (!isValidEnvSecretRefId(candidate)) { - return ( - params.copy?.envVarFormatError ?? - 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).' - ); - } - if (!process.env[candidate]?.trim()) { - return ( - params.copy?.envVarMissingError?.(candidate) ?? - `Environment variable "${candidate}" is missing or empty in this session.` - ); - } - return undefined; - }, - }); - const envCandidate = String(envVarRaw ?? "").trim(); - const envVar = - envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar; - if (!envVar) { - throw new Error( - `No valid environment variable name provided for provider "${params.provider}".`, - ); - } - const ref: SecretRef = { - source: "env", - provider: resolveDefaultSecretProviderAlias(params.config, "env", { - preferFirstProviderForSource: true, - }), - id: envVar, - }; - const resolvedValue = await resolveSecretRefString(ref, { - config: params.config, - env: process.env, - }); - await params.prompter.note( - params.copy?.envValidatedMessage?.(envVar) ?? - `Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`, - "Reference validated", - ); - return { ref, resolvedValue }; - } - - const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter( - ([, provider]) => provider?.source === "file" || provider?.source === "exec", - ); - if (externalProviders.length === 0) { - await params.prompter.note( - params.copy?.noProvidersMessage ?? - "No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.", - "No providers configured", - ); - continue; - } - const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", { - preferFirstProviderForSource: true, - }); - const selectedProvider = await params.prompter.select({ - message: "Select secret provider", - initialValue: - externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ?? - externalProviders[0]?.[0], - options: externalProviders.map(([providerName, provider]) => ({ - value: providerName, - label: providerName, - hint: provider?.source === "exec" ? "Exec provider" : "File provider", - })), - }); - const providerEntry = params.config.secrets?.providers?.[selectedProvider]; - if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) { - await params.prompter.note( - `Provider "${selectedProvider}" is not a file/exec provider.`, - "Invalid provider", - ); - continue; - } - const idPrompt = - providerEntry.source === "file" - ? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)" - : "Secret id for the exec provider"; - const idDefault = - providerEntry.source === "file" - ? providerEntry.mode === "singleValue" - ? "value" - : defaultFilePointer - : `${params.provider}/apiKey`; - const idRaw = await params.prompter.text({ - message: idPrompt, - initialValue: idDefault, - placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key", - validate: (value) => { - const candidate = value.trim(); - if (!candidate) { - return "Secret id cannot be empty."; - } - if ( - providerEntry.source === "file" && - providerEntry.mode !== "singleValue" && - !isValidFileSecretRefId(candidate) - ) { - return 'Use an absolute JSON pointer like "/providers/openai/apiKey".'; - } - if ( - providerEntry.source === "file" && - providerEntry.mode === "singleValue" && - candidate !== "value" - ) { - return 'singleValue mode expects id "value".'; - } - if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { - return formatExecSecretRefIdValidationMessage(); - } - return undefined; - }, - }); - const id = String(idRaw ?? "").trim() || idDefault; - const ref: SecretRef = { - source: providerEntry.source, - provider: selectedProvider, - id, - }; - try { - const resolvedValue = await resolveSecretRefString(ref, { - config: params.config, - env: process.env, - }); - await params.prompter.note( - params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ?? - `Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`, - "Reference validated", - ); - return { ref, resolvedValue }; - } catch (error) { - await params.prompter.note( - [ - `Could not validate provider reference ${selectedProvider}:${id}.`, - formatErrorMessage(error), - "Check your provider configuration and try again.", - ].join("\n"), - "Reference check failed", - ); - } - } -} +export type { + SecretInputModePromptCopy, + SecretRefSetupPromptCopy, +} from "../plugins/provider-auth-input.js"; +export { + ensureApiKeyFromEnvOrPrompt, + ensureApiKeyFromOptionEnvOrPrompt, + maybeApplyApiKeyFromOption, + normalizeSecretInputModeInput, + normalizeTokenProviderInput, + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../plugins/provider-auth-input.js"; export function createAuthChoiceAgentModelNoter( params: ApplyAuthChoiceParams, @@ -358,180 +97,3 @@ export function createAuthChoiceDefaultModelApplierForMutableState( }), ); } - -export function normalizeTokenProviderInput( - tokenProvider: string | null | undefined, -): string | undefined { - const normalized = String(tokenProvider ?? "") - .trim() - .toLowerCase(); - return normalized || undefined; -} - -export function normalizeSecretInputModeInput( - secretInputMode: string | null | undefined, -): SecretInputMode | undefined { - const normalized = String(secretInputMode ?? "") - .trim() - .toLowerCase(); - if (normalized === "plaintext" || normalized === "ref") { - return normalized; - } - return undefined; -} - -export async function resolveSecretInputModeForEnvSelection(params: { - prompter: WizardPrompter; - explicitMode?: SecretInputMode; - copy?: SecretInputModePromptCopy; -}): Promise { - if (params.explicitMode) { - return params.explicitMode; - } - // Some tests pass partial prompt harnesses without a select implementation. - // Preserve backward-compatible behavior by defaulting to plaintext in that case. - if (typeof params.prompter.select !== "function") { - return "plaintext"; - } - const selected = await params.prompter.select({ - message: params.copy?.modeMessage ?? "How do you want to provide this API key?", - initialValue: "plaintext", - options: [ - { - value: "plaintext", - label: params.copy?.plaintextLabel ?? "Paste API key now", - hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config", - }, - { - value: "ref", - label: params.copy?.refLabel ?? "Use external secret provider", - hint: - params.copy?.refHint ?? - "Stores a reference to env or configured external secret providers", - }, - ], - }); - return selected === "ref" ? "ref" : "plaintext"; -} - -export async function maybeApplyApiKeyFromOption(params: { - token: string | undefined; - tokenProvider: string | undefined; - secretInputMode?: SecretInputMode; - expectedProviders: string[]; - normalize: (value: string) => string; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; -}): Promise { - const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); - const expectedProviders = params.expectedProviders - .map((provider) => normalizeTokenProviderInput(provider)) - .filter((provider): provider is string => Boolean(provider)); - if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) { - return undefined; - } - const apiKey = params.normalize(params.token); - await params.setCredential(apiKey, params.secretInputMode); - return apiKey; -} - -export async function ensureApiKeyFromOptionEnvOrPrompt(params: { - token: string | undefined; - tokenProvider: string | undefined; - secretInputMode?: SecretInputMode; - config: OpenClawConfig; - expectedProviders: string[]; - provider: string; - envLabel: string; - promptMessage: string; - normalize: (value: string) => string; - validate: (value: string) => string | undefined; - prompter: WizardPrompter; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; - noteMessage?: string; - noteTitle?: string; -}): Promise { - const optionApiKey = await maybeApplyApiKeyFromOption({ - token: params.token, - tokenProvider: params.tokenProvider, - secretInputMode: params.secretInputMode, - expectedProviders: params.expectedProviders, - normalize: params.normalize, - setCredential: params.setCredential, - }); - if (optionApiKey) { - return optionApiKey; - } - - if (params.noteMessage) { - await params.prompter.note(params.noteMessage, params.noteTitle); - } - - return await ensureApiKeyFromEnvOrPrompt({ - config: params.config, - provider: params.provider, - envLabel: params.envLabel, - promptMessage: params.promptMessage, - normalize: params.normalize, - validate: params.validate, - prompter: params.prompter, - secretInputMode: params.secretInputMode, - setCredential: params.setCredential, - }); -} - -export async function ensureApiKeyFromEnvOrPrompt(params: { - config: OpenClawConfig; - provider: string; - envLabel: string; - promptMessage: string; - normalize: (value: string) => string; - validate: (value: string) => string | undefined; - prompter: WizardPrompter; - secretInputMode?: SecretInputMode; - setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; -}): Promise { - const selectedMode = await resolveSecretInputModeForEnvSelection({ - prompter: params.prompter, - explicitMode: params.secretInputMode, - }); - const envKey = resolveEnvApiKey(params.provider); - - if (selectedMode === "ref") { - if (typeof params.prompter.select !== "function") { - const fallback = resolveRefFallbackInput({ - config: params.config, - provider: params.provider, - preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, - }); - await params.setCredential(fallback.ref, selectedMode); - return fallback.resolvedValue; - } - const resolved = await promptSecretRefForSetup({ - provider: params.provider, - config: params.config, - prompter: params.prompter, - preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, - }); - await params.setCredential(resolved.ref, selectedMode); - return resolved.resolvedValue; - } - - if (envKey && selectedMode === "plaintext") { - const useExisting = await params.prompter.confirm({ - message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, - initialValue: true, - }); - if (useExisting) { - await params.setCredential(envKey.apiKey, selectedMode); - return envKey.apiKey; - } - } - - const key = await params.prompter.text({ - message: params.promptMessage, - validate: params.validate, - }); - const apiKey = params.normalize(String(key ?? "")); - await params.setCredential(apiKey, selectedMode); - return apiKey; -} diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index afdad97ecec..ce459020039 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -8,7 +8,7 @@ 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 } from "../plugins/types.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"; @@ -97,7 +97,7 @@ export async function runProviderPluginAuthMethod(params: { workspaceDir, prompter: params.prompter, runtime: params.runtime, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag | undefined, secretInputMode: params.secretInputMode, allowSecretRefPrompt: params.allowSecretRefPrompt, isRemote, @@ -173,7 +173,7 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: false, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag | undefined, }); let nextConfig = applied.config; @@ -260,7 +260,7 @@ export async function applyAuthChoicePluginProvider( workspaceDir, secretInputMode: params.opts?.secretInputMode, allowSecretRefPrompt: false, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag | undefined, }); nextConfig = applied.config; diff --git a/src/commands/auth-token.ts b/src/commands/auth-token.ts index d003c2aa1b7..b371599b222 100644 --- a/src/commands/auth-token.ts +++ b/src/commands/auth-token.ts @@ -1,38 +1,8 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; - -export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-"; -export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80; -export const DEFAULT_TOKEN_PROFILE_NAME = "default"; - -export function normalizeTokenProfileName(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return DEFAULT_TOKEN_PROFILE_NAME; - } - const slug = trimmed - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); - return slug || DEFAULT_TOKEN_PROFILE_NAME; -} - -export function buildTokenProfileId(params: { provider: string; name: string }): string { - const provider = normalizeProviderId(params.provider); - const name = normalizeTokenProfileName(params.name); - return `${provider}:${name}`; -} - -export function validateAnthropicSetupToken(raw: string): string | undefined { - const trimmed = raw.trim(); - if (!trimmed) { - return "Required"; - } - if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) { - return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`; - } - if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) { - return "Token looks too short; paste the full setup-token"; - } - return undefined; -} +export { + ANTHROPIC_SETUP_TOKEN_MIN_LENGTH, + ANTHROPIC_SETUP_TOKEN_PREFIX, + buildTokenProfileId, + DEFAULT_TOKEN_PROFILE_NAME, + normalizeTokenProfileName, + validateAnthropicSetupToken, +} from "../plugins/provider-auth-token.js"; diff --git a/src/commands/google-gemini-model-default.ts b/src/commands/google-gemini-model-default.ts index 491fdd3c6d9..25b92d6459f 100644 --- a/src/commands/google-gemini-model-default.ts +++ b/src/commands/google-gemini-model-default.ts @@ -1,11 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; - -export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL }); -} +export { + applyGoogleGeminiModelDefault, + GOOGLE_GEMINI_DEFAULT_MODEL, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/model-allowlist.ts b/src/commands/model-allowlist.ts index bc6dfc5308d..37f664aef36 100644 --- a/src/commands/model-allowlist.ts +++ b/src/commands/model-allowlist.ts @@ -1,41 +1 @@ -import { DEFAULT_PROVIDER } from "../agents/defaults.js"; -import { resolveAllowlistModelKey } from "../agents/model-selection.js"; -import type { OpenClawConfig } from "../config/config.js"; - -export function ensureModelAllowlistEntry(params: { - cfg: OpenClawConfig; - modelRef: string; - defaultProvider?: string; -}): OpenClawConfig { - const rawModelRef = params.modelRef.trim(); - if (!rawModelRef) { - return params.cfg; - } - - const models = { ...params.cfg.agents?.defaults?.models }; - const keySet = new Set([rawModelRef]); - const canonicalKey = resolveAllowlistModelKey( - rawModelRef, - params.defaultProvider ?? DEFAULT_PROVIDER, - ); - if (canonicalKey) { - keySet.add(canonicalKey); - } - - for (const key of keySet) { - models[key] = { - ...models[key], - }; - } - - return { - ...params.cfg, - agents: { - ...params.cfg.agents, - defaults: { - ...params.cfg.agents?.defaults, - models, - }, - }, - }; -} +export { ensureModelAllowlistEntry } from "../plugins/provider-model-allowlist.js"; diff --git a/src/commands/model-default.ts b/src/commands/model-default.ts index ce121973da3..d70e5208f3b 100644 --- a/src/commands/model-default.ts +++ b/src/commands/model-default.ts @@ -1,45 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { AgentModelListConfig } from "../config/types.js"; - -export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { - if (typeof model === "string") { - return model; - } - if (model && typeof model === "object" && typeof model.primary === "string") { - return model.primary; - } - return undefined; -} - -export function applyAgentDefaultPrimaryModel(params: { - cfg: OpenClawConfig; - model: string; - legacyModels?: Set; -}): { next: OpenClawConfig; changed: boolean } { - const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim(); - const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current; - if (normalizedCurrent === params.model) { - return { next: params.cfg, changed: false }; - } - - return { - next: { - ...params.cfg, - agents: { - ...params.cfg.agents, - defaults: { - ...params.cfg.agents?.defaults, - model: - params.cfg.agents?.defaults?.model && - typeof params.cfg.agents.defaults.model === "object" - ? { - ...params.cfg.agents.defaults.model, - primary: params.model, - } - : { primary: params.model }, - }, - }, - }, - changed: true, - }; -} +export { + applyAgentDefaultPrimaryModel, + resolvePrimaryModel, +} from "../plugins/provider-model-primary.js"; diff --git a/src/commands/model-picker.ts b/src/commands/model-picker.ts index 483997511cb..c0b67ea7d7c 100644 --- a/src/commands/model-picker.ts +++ b/src/commands/model-picker.ts @@ -11,10 +11,13 @@ import { } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; import type { ProviderPlugin } from "../plugins/types.js"; import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js"; import { formatTokenK } from "./models/shared.js"; +export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; + const KEEP_VALUE = "__keep__"; const MANUAL_VALUE = "__manual__"; const PROVIDER_FILTER_THRESHOLD = 30; @@ -516,33 +519,6 @@ export async function promptModelAllowlist(params: { return { models: [] }; } -export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig { - const defaults = cfg.agents?.defaults; - const existingModel = defaults?.model; - const existingModels = defaults?.models; - const fallbacks = - typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel - ? (existingModel as { fallbacks?: string[] }).fallbacks - : undefined; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...defaults, - model: { - ...(fallbacks ? { fallbacks } : undefined), - primary: model, - }, - models: { - ...existingModels, - [model]: existingModels?.[model] ?? {}, - }, - }, - }, - }; -} - export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig { const defaults = cfg.agents?.defaults; const normalized = normalizeModelKeys(models); diff --git a/src/commands/oauth-flow.ts b/src/commands/oauth-flow.ts index 1b0eba3b4f8..48e89b25720 100644 --- a/src/commands/oauth-flow.ts +++ b/src/commands/oauth-flow.ts @@ -1,53 +1 @@ -import type { RuntimeEnv } from "../runtime.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; - -type OAuthPrompt = { message: string; placeholder?: string }; - -const validateRequiredInput = (value: string) => (value.trim().length > 0 ? undefined : "Required"); - -export function createVpsAwareOAuthHandlers(params: { - isRemote: boolean; - prompter: WizardPrompter; - runtime: RuntimeEnv; - spin: ReturnType; - openUrl: (url: string) => Promise; - localBrowserMessage: string; - manualPromptMessage?: string; -}): { - onAuth: (event: { url: string }) => Promise; - onPrompt: (prompt: OAuthPrompt) => Promise; -} { - const manualPromptMessage = params.manualPromptMessage ?? "Paste the redirect URL"; - let manualCodePromise: Promise | undefined; - - return { - onAuth: async ({ url }) => { - if (params.isRemote) { - params.spin.stop("OAuth URL ready"); - params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); - manualCodePromise = params.prompter - .text({ - message: manualPromptMessage, - validate: validateRequiredInput, - }) - .then((value) => String(value)); - return; - } - - params.spin.update(params.localBrowserMessage); - await params.openUrl(url); - params.runtime.log(`Open: ${url}`); - }, - onPrompt: async (prompt) => { - if (manualCodePromise) { - return manualCodePromise; - } - const code = await params.prompter.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: validateRequiredInput, - }); - return String(code); - }, - }; -} +export * from "../plugins/provider-oauth-flow.js"; diff --git a/src/commands/oauth-tls-preflight.ts b/src/commands/oauth-tls-preflight.ts index bf9e69b0519..6852c58ad5c 100644 --- a/src/commands/oauth-tls-preflight.ts +++ b/src/commands/oauth-tls-preflight.ts @@ -1,164 +1 @@ -import path from "node:path"; -import { formatCliCommand } from "../cli/command-format.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { note } from "../terminal/note.js"; - -const TLS_CERT_ERROR_CODES = new Set([ - "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", - "UNABLE_TO_VERIFY_LEAF_SIGNATURE", - "CERT_HAS_EXPIRED", - "DEPTH_ZERO_SELF_SIGNED_CERT", - "SELF_SIGNED_CERT_IN_CHAIN", - "ERR_TLS_CERT_ALTNAME_INVALID", -]); - -const TLS_CERT_ERROR_PATTERNS = [ - /unable to get local issuer certificate/i, - /unable to verify the first certificate/i, - /self[- ]signed certificate/i, - /certificate has expired/i, -]; - -const OPENAI_AUTH_PROBE_URL = - "https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email"; - -type PreflightFailureKind = "tls-cert" | "network"; - -export type OpenAIOAuthTlsPreflightResult = - | { ok: true } - | { - ok: false; - kind: PreflightFailureKind; - code?: string; - message: string; - }; - -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" ? (value as Record) : null; -} - -function extractFailure(error: unknown): { - code?: string; - message: string; - kind: PreflightFailureKind; -} { - const root = asRecord(error); - const rootCause = asRecord(root?.cause); - const code = typeof rootCause?.code === "string" ? rootCause.code : undefined; - const message = - typeof rootCause?.message === "string" - ? rootCause.message - : typeof root?.message === "string" - ? root.message - : String(error); - const isTlsCertError = - (code ? TLS_CERT_ERROR_CODES.has(code) : false) || - TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); - return { - code, - message, - kind: isTlsCertError ? "tls-cert" : "network", - }; -} - -function resolveHomebrewPrefixFromExecPath(execPath: string): string | null { - const marker = `${path.sep}Cellar${path.sep}`; - const idx = execPath.indexOf(marker); - if (idx > 0) { - return execPath.slice(0, idx); - } - const envPrefix = process.env.HOMEBREW_PREFIX?.trim(); - return envPrefix ? envPrefix : null; -} - -function resolveCertBundlePath(): string | null { - const prefix = resolveHomebrewPrefixFromExecPath(process.execPath); - if (!prefix) { - return null; - } - return path.join(prefix, "etc", "openssl@3", "cert.pem"); -} - -function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean { - const profiles = cfg.auth?.profiles; - if (!profiles) { - return false; - } - return Object.values(profiles).some( - (profile) => profile.provider === "openai-codex" && profile.mode === "oauth", - ); -} - -function shouldRunOpenAIOAuthTlsPrerequisites(params: { - cfg: OpenClawConfig; - deep?: boolean; -}): boolean { - if (params.deep === true) { - return true; - } - return hasOpenAICodexOAuthProfile(params.cfg); -} - -export async function runOpenAIOAuthTlsPreflight(options?: { - timeoutMs?: number; - fetchImpl?: typeof fetch; -}): Promise { - const timeoutMs = options?.timeoutMs ?? 5000; - const fetchImpl = options?.fetchImpl ?? fetch; - try { - await fetchImpl(OPENAI_AUTH_PROBE_URL, { - method: "GET", - redirect: "manual", - signal: AbortSignal.timeout(timeoutMs), - }); - return { ok: true }; - } catch (error) { - const failure = extractFailure(error); - return { - ok: false, - kind: failure.kind, - code: failure.code, - message: failure.message, - }; - } -} - -export function formatOpenAIOAuthTlsPreflightFix( - result: Exclude, -): string { - if (result.kind !== "tls-cert") { - return [ - "OpenAI OAuth prerequisites check failed due to a network error before the browser flow.", - `Cause: ${result.message}`, - "Verify DNS/firewall/proxy access to auth.openai.com and retry.", - ].join("\n"); - } - const certBundlePath = resolveCertBundlePath(); - const lines = [ - "OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.", - `Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`, - "", - "Fix (Homebrew Node/OpenSSL):", - `- ${formatCliCommand("brew postinstall ca-certificates")}`, - `- ${formatCliCommand("brew postinstall openssl@3")}`, - ]; - if (certBundlePath) { - lines.push(`- Verify cert bundle exists: ${certBundlePath}`); - } - lines.push("- Retry the OAuth login flow."); - return lines.join("\n"); -} - -export async function noteOpenAIOAuthTlsPrerequisites(params: { - cfg: OpenClawConfig; - deep?: boolean; -}): Promise { - if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) { - return; - } - const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 }); - if (result.ok || result.kind !== "tls-cert") { - return; - } - note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites"); -} +export * from "../plugins/provider-openai-codex-oauth-tls.js"; 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 3f11a7367a9..54f25857441 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 @@ -8,6 +8,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { enablePluginInConfig } from "../../../plugins/enable.js"; import type { + ProviderAuthOptionBag, ProviderNonInteractiveApiKeyCredentialParams, ProviderResolveNonInteractiveApiKeyParams, } from "../../../plugins/types.js"; @@ -130,7 +131,7 @@ export async function applyNonInteractivePluginProviderChoice(params: { authChoice: params.authChoice, config: enableResult.config, baseConfig: params.baseConfig, - opts: params.opts, + opts: params.opts as ProviderAuthOptionBag, runtime: params.runtime, agentDir, workspaceDir, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 9d738298e52..832fae75448 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -1,4 +1,5 @@ import type { ChannelId } from "../channels/plugins/types.js"; +import type { SecretInputMode } from "../plugins/provider-auth-types.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; export type OnboardMode = "local" | "remote"; @@ -90,7 +91,7 @@ export type NodeManagerChoice = "npm" | "pnpm" | "bun"; export type ChannelChoice = ChannelId; // Legacy alias (pre-rename). export type ProviderChoice = ChannelChoice; -export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret +export type { SecretInputMode } from "../plugins/provider-auth-types.js"; export type OnboardOptions = { mode?: OnboardMode; diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index a868217750b..0c5f098c41f 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -1,65 +1 @@ -import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; -import type { RuntimeEnv } from "../runtime.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { - formatOpenAIOAuthTlsPreflightFix, - runOpenAIOAuthTlsPreflight, -} from "./oauth-tls-preflight.js"; - -export async function loginOpenAICodexOAuth(params: { - prompter: WizardPrompter; - runtime: RuntimeEnv; - isRemote: boolean; - openUrl: (url: string) => Promise; - localBrowserMessage?: string; -}): Promise { - const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; - const preflight = await runOpenAIOAuthTlsPreflight(); - if (!preflight.ok && preflight.kind === "tls-cert") { - const hint = formatOpenAIOAuthTlsPreflightFix(preflight); - runtime.error(hint); - await prompter.note(hint, "OAuth prerequisites"); - throw new Error(preflight.message); - } - - await prompter.note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), - "OpenAI Codex OAuth", - ); - - const spin = prompter.progress("Starting OAuth flow…"); - try { - const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({ - isRemote, - prompter, - runtime, - spin, - openUrl, - localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", - }); - - const creds = await loginOpenAICodex({ - onAuth: baseOnAuth, - onPrompt, - onProgress: (msg: string) => spin.update(msg), - }); - spin.stop("OpenAI OAuth complete"); - return creds ?? null; - } catch (err) { - spin.stop("OpenAI OAuth failed"); - runtime.error(String(err)); - await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help"); - throw err; - } -} +export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; diff --git a/src/commands/openai-model-default.ts b/src/commands/openai-model-default.ts index 191756e0fa0..81316e753ed 100644 --- a/src/commands/openai-model-default.ts +++ b/src/commands/openai-model-default.ts @@ -1,47 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { ensureModelAllowlistEntry } from "./model-allowlist.js"; - -export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex"; - -export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = ensureModelAllowlistEntry({ - cfg, - modelRef: OPENAI_DEFAULT_MODEL, - }); - const models = { ...next.agents?.defaults?.models }; - models[OPENAI_DEFAULT_MODEL] = { - ...models[OPENAI_DEFAULT_MODEL], - alias: models[OPENAI_DEFAULT_MODEL]?.alias ?? "GPT", - }; - - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpenAIProviderConfig(cfg); - return { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - model: - next.agents?.defaults?.model && typeof next.agents.defaults.model === "object" - ? { - ...next.agents.defaults.model, - primary: OPENAI_DEFAULT_MODEL, - } - : { primary: OPENAI_DEFAULT_MODEL }, - }, - }, - }; -} +export { + applyOpenAIConfig, + applyOpenAIProviderConfig, + OPENAI_DEFAULT_MODEL, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/opencode-go-model-default.ts b/src/commands/opencode-go-model-default.ts index c959f23ff2e..c87816456c3 100644 --- a/src/commands/opencode-go-model-default.ts +++ b/src/commands/opencode-go-model-default.ts @@ -1,11 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5"; - -export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF }); -} +export { + applyOpencodeGoModelDefault, + OPENCODE_GO_DEFAULT_MODEL_REF, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/opencode-zen-model-default.ts b/src/commands/opencode-zen-model-default.ts index 9efb9c17ade..0d874241076 100644 --- a/src/commands/opencode-zen-model-default.ts +++ b/src/commands/opencode-zen-model-default.ts @@ -1,19 +1,4 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultPrimaryModel } from "./model-default.js"; - -export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; -const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ - "opencode/claude-opus-4-5", - "opencode-zen/claude-opus-4-5", -]); - -export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - return applyAgentDefaultPrimaryModel({ - cfg, - model: OPENCODE_ZEN_DEFAULT_MODEL, - legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS, - }); -} +export { + applyOpencodeZenModelDefault, + OPENCODE_ZEN_DEFAULT_MODEL, +} from "../plugins/provider-model-defaults.js"; diff --git a/src/commands/self-hosted-provider-setup.ts b/src/commands/self-hosted-provider-setup.ts index e7851fdf550..2b1e0a3027b 100644 --- a/src/commands/self-hosted-provider-setup.ts +++ b/src/commands/self-hosted-provider-setup.ts @@ -13,6 +13,7 @@ import type { ProviderAuthMethodNonInteractiveContext, ProviderNonInteractiveApiKeyResult, } from "../plugins/types.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import type { WizardPrompter } from "../wizard/prompts.js"; export { @@ -240,11 +241,10 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive( contextWindow?: number; maxTokens?: number; }): Promise { - const baseUrl = (params.ctx.opts.customBaseUrl?.trim() || params.defaultBaseUrl).replace( - /\/+$/, - "", - ); - const modelId = params.ctx.opts.customModelId?.trim(); + const baseUrl = ( + normalizeOptionalSecretInput(params.ctx.opts.customBaseUrl) ?? params.defaultBaseUrl + ).replace(/\/+$/, ""); + const modelId = normalizeOptionalSecretInput(params.ctx.opts.customModelId); if (!modelId) { params.ctx.runtime.error( buildMissingNonInteractiveModelIdMessage({ @@ -259,7 +259,7 @@ export async function configureOpenAICompatibleSelfHostedProviderNonInteractive( const resolved = await params.ctx.resolveApiKey({ provider: params.providerId, - flagValue: params.ctx.opts.customApiKey, + flagValue: normalizeOptionalSecretInput(params.ctx.opts.customApiKey), flagName: "--custom-api-key", envVar: params.defaultApiKeyEnvVar, envVarName: params.defaultApiKeyEnvVar, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index bb0c307c294..baecefe62e9 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -21,17 +21,20 @@ export { formatApiKeyPreview, normalizeApiKeyInput, validateApiKeyInput, -} from "../commands/auth-choice.api-key.js"; +} from "../plugins/provider-auth-input.js"; export { ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, promptSecretRefForSetup, resolveSecretInputModeForEnvSelection, -} from "../commands/auth-choice.apply-helpers.js"; -export { buildTokenProfileId, validateAnthropicSetupToken } from "../commands/auth-token.js"; +} from "../plugins/provider-auth-input.js"; +export { + buildTokenProfileId, + validateAnthropicSetupToken, +} from "../plugins/provider-auth-token.js"; export { applyAuthProfileConfig, buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; export { githubCopilotLoginCommand } from "../providers/github-copilot-auth.js"; -export { loginOpenAICodexOAuth } from "../commands/openai-codex-oauth.js"; +export { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; export { coerceSecretRef } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index f0a85fe1ed1..5694a540075 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -14,10 +14,10 @@ export { normalizeProviderId } from "../agents/provider-id.js"; export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, -} from "../commands/google-gemini-model-default.js"; -export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../commands/openai-model-default.js"; -export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../commands/opencode-go-model-default.js"; -export { OPENCODE_ZEN_DEFAULT_MODEL } from "../commands/opencode-zen-model-default.js"; +} from "../plugins/provider-model-defaults.js"; +export { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js"; +export { OPENCODE_GO_DEFAULT_MODEL_REF } from "../plugins/provider-model-defaults.js"; +export { OPENCODE_ZEN_DEFAULT_MODEL } from "../plugins/provider-model-defaults.js"; export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; export * from "../plugins/provider-model-definitions.js"; diff --git a/src/plugin-sdk/provider-onboard.ts b/src/plugin-sdk/provider-onboard.ts index 89b219bedbc..35b9287bcc8 100644 --- a/src/plugin-sdk/provider-onboard.ts +++ b/src/plugin-sdk/provider-onboard.ts @@ -13,4 +13,4 @@ export { applyProviderConfigWithDefaultModels, applyProviderConfigWithModelCatalog, } from "../plugins/provider-onboarding-config.js"; -export { ensureModelAllowlistEntry } from "../commands/model-allowlist.js"; +export { ensureModelAllowlistEntry } from "../plugins/provider-model-allowlist.js"; diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index dade8720478..ad37b986b91 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -1,7 +1,10 @@ -import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; -import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; -import { applyPrimaryModel } from "../commands/model-picker.js"; import { applyAuthProfileConfig, buildApiKeyCredential } from "./provider-auth-helpers.js"; +import { + ensureApiKeyFromOptionEnvOrPrompt, + normalizeApiKeyInput, + validateApiKeyInput, +} from "./provider-auth-input.js"; +import { applyPrimaryModel } from "./provider-model-primary.js"; export { applyAuthProfileConfig, diff --git a/src/plugins/provider-auth-helpers.ts b/src/plugins/provider-auth-helpers.ts index 72075dffc00..bf397044eae 100644 --- a/src/plugins/provider-auth-helpers.ts +++ b/src/plugins/provider-auth-helpers.ts @@ -4,7 +4,6 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; -import type { SecretInputMode } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { @@ -15,6 +14,7 @@ import { } from "../config/types.secrets.js"; import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import type { SecretInputMode } from "./provider-auth-types.js"; const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; diff --git a/src/plugins/provider-auth-input.ts b/src/plugins/provider-auth-input.ts new file mode 100644 index 00000000000..02abf92592d --- /dev/null +++ b/src/plugins/provider-auth-input.ts @@ -0,0 +1,496 @@ +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { + isValidEnvSecretRefId, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; +import { encodeJsonPointerToken } from "../secrets/json-pointer.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { + formatExecSecretRefIdValidationMessage, + isValidExecSecretRefId, + isValidFileSecretRefId, + resolveDefaultSecretProviderAlias, +} from "../secrets/ref-contract.js"; +import { resolveSecretRefString } from "../secrets/resolve.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import type { SecretInputMode } from "./provider-auth-types.js"; + +const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 }; +const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; + +type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret + +export type SecretInputModePromptCopy = { + modeMessage?: string; + plaintextLabel?: string; + plaintextHint?: string; + refLabel?: string; + refHint?: string; +}; + +export type SecretRefSetupPromptCopy = { + sourceMessage?: string; + envVarMessage?: string; + envVarPlaceholder?: string; + envVarFormatError?: string; + envVarMissingError?: (envVar: string) => string; + noProvidersMessage?: string; + envValidatedMessage?: (envVar: string) => string; + providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string; +}; + +export function normalizeApiKeyInput(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) { + return ""; + } + + const assignmentMatch = trimmed.match(/^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/); + const valuePart = assignmentMatch ? assignmentMatch[1].trim() : trimmed; + + const unquoted = + valuePart.length >= 2 && + ((valuePart.startsWith('"') && valuePart.endsWith('"')) || + (valuePart.startsWith("'") && valuePart.endsWith("'")) || + (valuePart.startsWith("`") && valuePart.endsWith("`"))) + ? valuePart.slice(1, -1) + : valuePart; + + const withoutSemicolon = unquoted.endsWith(";") ? unquoted.slice(0, -1) : unquoted; + + return withoutSemicolon.trim(); +} + +export const validateApiKeyInput = (value: string) => + normalizeApiKeyInput(value).length > 0 ? undefined : "Required"; + +export function formatApiKeyPreview( + raw: string, + opts: { head?: number; tail?: number } = {}, +): string { + const trimmed = raw.trim(); + if (!trimmed) { + return "…"; + } + const head = opts.head ?? DEFAULT_KEY_PREVIEW.head; + const tail = opts.tail ?? DEFAULT_KEY_PREVIEW.tail; + if (trimmed.length <= head + tail) { + const shortHead = Math.min(2, trimmed.length); + const shortTail = Math.min(2, trimmed.length - shortHead); + if (shortTail <= 0) { + return `${trimmed.slice(0, shortHead)}…`; + } + return `${trimmed.slice(0, shortHead)}…${trimmed.slice(-shortTail)}`; + } + return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`; +} + +function formatErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string" && error.message.trim()) { + return error.message; + } + return String(error); +} + +function extractEnvVarFromSourceLabel(source: string): string | undefined { + const match = ENV_SOURCE_LABEL_RE.exec(source.trim()); + return match?.[1]; +} + +function resolveDefaultProviderEnvVar(provider: string): string | undefined { + const envVars = PROVIDER_ENV_VARS[provider]; + return envVars?.find((candidate) => candidate.trim().length > 0); +} + +function resolveDefaultFilePointerId(provider: string): string { + return `/providers/${encodeJsonPointerToken(provider)}/apiKey`; +} + +function resolveRefFallbackInput(params: { + config: OpenClawConfig; + provider: string; + preferredEnvVar?: string; +}): { ref: SecretRef; resolvedValue: string } { + const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider); + if (!fallbackEnvVar) { + throw new Error( + `No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`, + ); + } + const value = process.env[fallbackEnvVar]?.trim(); + if (!value) { + throw new Error( + `Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`, + ); + } + return { + ref: { + source: "env", + provider: resolveDefaultSecretProviderAlias(params.config, "env", { + preferFirstProviderForSource: true, + }), + id: fallbackEnvVar, + }, + resolvedValue: value, + }; +} + +export async function promptSecretRefForSetup(params: { + provider: string; + config: OpenClawConfig; + prompter: WizardPrompter; + preferredEnvVar?: string; + copy?: SecretRefSetupPromptCopy; +}): Promise<{ ref: SecretRef; resolvedValue: string }> { + const defaultEnvVar = + params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? ""; + const defaultFilePointer = resolveDefaultFilePointerId(params.provider); + let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret + + while (true) { + const sourceRaw: SecretRefChoice = await params.prompter.select({ + message: params.copy?.sourceMessage ?? "Where is this API key stored?", + initialValue: sourceChoice, + options: [ + { + value: "env", + label: "Environment variable", + hint: "Reference a variable from your runtime environment", + }, + { + value: "provider", + label: "Configured secret provider", + hint: "Use a configured file or exec secret provider", + }, + ], + }); + const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env"; + sourceChoice = source; + + if (source === "env") { + const envVarRaw = await params.prompter.text({ + message: params.copy?.envVarMessage ?? "Environment variable name", + initialValue: defaultEnvVar || undefined, + placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY", + validate: (value) => { + const candidate = value.trim(); + if (!isValidEnvSecretRefId(candidate)) { + return ( + params.copy?.envVarFormatError ?? + 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).' + ); + } + if (!process.env[candidate]?.trim()) { + return ( + params.copy?.envVarMissingError?.(candidate) ?? + `Environment variable "${candidate}" is missing or empty in this session.` + ); + } + return undefined; + }, + }); + const envCandidate = String(envVarRaw ?? "").trim(); + const envVar = + envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar; + if (!envVar) { + throw new Error( + `No valid environment variable name provided for provider "${params.provider}".`, + ); + } + const ref: SecretRef = { + source: "env", + provider: resolveDefaultSecretProviderAlias(params.config, "env", { + preferFirstProviderForSource: true, + }), + id: envVar, + }; + const resolvedValue = await resolveSecretRefString(ref, { + config: params.config, + env: process.env, + }); + await params.prompter.note( + params.copy?.envValidatedMessage?.(envVar) ?? + `Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`, + "Reference validated", + ); + return { ref, resolvedValue }; + } + + const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter( + ([, provider]) => provider?.source === "file" || provider?.source === "exec", + ); + if (externalProviders.length === 0) { + await params.prompter.note( + params.copy?.noProvidersMessage ?? + "No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.", + "No providers configured", + ); + continue; + } + const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", { + preferFirstProviderForSource: true, + }); + const selectedProvider = await params.prompter.select({ + message: "Select secret provider", + initialValue: + externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ?? + externalProviders[0]?.[0], + options: externalProviders.map(([providerName, provider]) => ({ + value: providerName, + label: providerName, + hint: provider?.source === "exec" ? "Exec provider" : "File provider", + })), + }); + const providerEntry = params.config.secrets?.providers?.[selectedProvider]; + if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) { + await params.prompter.note( + `Provider "${selectedProvider}" is not a file/exec provider.`, + "Invalid provider", + ); + continue; + } + const idPrompt = + providerEntry.source === "file" + ? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)" + : "Secret id for the exec provider"; + const idDefault = + providerEntry.source === "file" + ? providerEntry.mode === "singleValue" + ? "value" + : defaultFilePointer + : `${params.provider}/apiKey`; + const idRaw = await params.prompter.text({ + message: idPrompt, + initialValue: idDefault, + placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key", + validate: (value) => { + const candidate = value.trim(); + if (!candidate) { + return "Secret id cannot be empty."; + } + if ( + providerEntry.source === "file" && + providerEntry.mode !== "singleValue" && + !isValidFileSecretRefId(candidate) + ) { + return 'Use an absolute JSON pointer like "/providers/openai/apiKey".'; + } + if ( + providerEntry.source === "file" && + providerEntry.mode === "singleValue" && + candidate !== "value" + ) { + return 'singleValue mode expects id "value".'; + } + if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { + return formatExecSecretRefIdValidationMessage(); + } + return undefined; + }, + }); + const id = String(idRaw ?? "").trim() || idDefault; + const ref: SecretRef = { + source: providerEntry.source, + provider: selectedProvider, + id, + }; + try { + const resolvedValue = await resolveSecretRefString(ref, { + config: params.config, + env: process.env, + }); + await params.prompter.note( + params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ?? + `Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`, + "Reference validated", + ); + return { ref, resolvedValue }; + } catch (error) { + await params.prompter.note( + [ + `Could not validate provider reference ${selectedProvider}:${id}.`, + formatErrorMessage(error), + "Check your provider configuration and try again.", + ].join("\n"), + "Reference check failed", + ); + } + } +} + +export function normalizeTokenProviderInput( + tokenProvider: string | null | undefined, +): string | undefined { + const normalized = String(tokenProvider ?? "") + .trim() + .toLowerCase(); + return normalized || undefined; +} + +export function normalizeSecretInputModeInput( + secretInputMode: string | null | undefined, +): SecretInputMode | undefined { + const normalized = String(secretInputMode ?? "") + .trim() + .toLowerCase(); + if (normalized === "plaintext" || normalized === "ref") { + return normalized; + } + return undefined; +} + +export async function resolveSecretInputModeForEnvSelection(params: { + prompter: WizardPrompter; + explicitMode?: SecretInputMode; + copy?: SecretInputModePromptCopy; +}): Promise { + if (params.explicitMode) { + return params.explicitMode; + } + if (typeof params.prompter.select !== "function") { + return "plaintext"; + } + const selected = await params.prompter.select({ + message: params.copy?.modeMessage ?? "How do you want to provide this API key?", + initialValue: "plaintext", + options: [ + { + value: "plaintext", + label: params.copy?.plaintextLabel ?? "Paste API key now", + hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config", + }, + { + value: "ref", + label: params.copy?.refLabel ?? "Use external secret provider", + hint: + params.copy?.refHint ?? + "Stores a reference to env or configured external secret providers", + }, + ], + }); + return selected === "ref" ? "ref" : "plaintext"; +} + +export async function maybeApplyApiKeyFromOption(params: { + token: string | undefined; + tokenProvider: string | undefined; + secretInputMode?: SecretInputMode; + expectedProviders: string[]; + normalize: (value: string) => string; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; +}): Promise { + const tokenProvider = normalizeTokenProviderInput(params.tokenProvider); + const expectedProviders = params.expectedProviders + .map((provider) => normalizeTokenProviderInput(provider)) + .filter((provider): provider is string => Boolean(provider)); + if (!params.token || !tokenProvider || !expectedProviders.includes(tokenProvider)) { + return undefined; + } + const apiKey = params.normalize(params.token); + await params.setCredential(apiKey, params.secretInputMode); + return apiKey; +} + +export async function ensureApiKeyFromOptionEnvOrPrompt(params: { + token: string | undefined; + tokenProvider: string | undefined; + secretInputMode?: SecretInputMode; + config: OpenClawConfig; + expectedProviders: string[]; + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; + noteMessage?: string; + noteTitle?: string; +}): Promise { + const optionApiKey = await maybeApplyApiKeyFromOption({ + token: params.token, + tokenProvider: params.tokenProvider, + secretInputMode: params.secretInputMode, + expectedProviders: params.expectedProviders, + normalize: params.normalize, + setCredential: params.setCredential, + }); + if (optionApiKey) { + return optionApiKey; + } + + if (params.noteMessage) { + await params.prompter.note(params.noteMessage, params.noteTitle); + } + + return await ensureApiKeyFromEnvOrPrompt({ + config: params.config, + provider: params.provider, + envLabel: params.envLabel, + promptMessage: params.promptMessage, + normalize: params.normalize, + validate: params.validate, + prompter: params.prompter, + secretInputMode: params.secretInputMode, + setCredential: params.setCredential, + }); +} + +export async function ensureApiKeyFromEnvOrPrompt(params: { + config: OpenClawConfig; + provider: string; + envLabel: string; + promptMessage: string; + normalize: (value: string) => string; + validate: (value: string) => string | undefined; + prompter: WizardPrompter; + secretInputMode?: SecretInputMode; + setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise; +}): Promise { + const selectedMode = await resolveSecretInputModeForEnvSelection({ + prompter: params.prompter, + explicitMode: params.secretInputMode, + }); + const envKey = resolveEnvApiKey(params.provider); + + if (selectedMode === "ref") { + if (typeof params.prompter.select !== "function") { + const fallback = resolveRefFallbackInput({ + config: params.config, + provider: params.provider, + preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, + }); + await params.setCredential(fallback.ref, selectedMode); + return fallback.resolvedValue; + } + const resolved = await promptSecretRefForSetup({ + provider: params.provider, + config: params.config, + prompter: params.prompter, + preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined, + }); + await params.setCredential(resolved.ref, selectedMode); + return resolved.resolvedValue; + } + + if (envKey && selectedMode === "plaintext") { + const useExisting = await params.prompter.confirm({ + message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`, + initialValue: true, + }); + if (useExisting) { + await params.setCredential(envKey.apiKey, selectedMode); + return envKey.apiKey; + } + } + + const key = await params.prompter.text({ + message: params.promptMessage, + validate: params.validate, + }); + const apiKey = params.normalize(String(key ?? "")); + await params.setCredential(apiKey, selectedMode); + return apiKey; +} diff --git a/src/plugins/provider-auth-token.ts b/src/plugins/provider-auth-token.ts new file mode 100644 index 00000000000..d003c2aa1b7 --- /dev/null +++ b/src/plugins/provider-auth-token.ts @@ -0,0 +1,38 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; + +export const ANTHROPIC_SETUP_TOKEN_PREFIX = "sk-ant-oat01-"; +export const ANTHROPIC_SETUP_TOKEN_MIN_LENGTH = 80; +export const DEFAULT_TOKEN_PROFILE_NAME = "default"; + +export function normalizeTokenProfileName(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return DEFAULT_TOKEN_PROFILE_NAME; + } + const slug = trimmed + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || DEFAULT_TOKEN_PROFILE_NAME; +} + +export function buildTokenProfileId(params: { provider: string; name: string }): string { + const provider = normalizeProviderId(params.provider); + const name = normalizeTokenProfileName(params.name); + return `${provider}:${name}`; +} + +export function validateAnthropicSetupToken(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return "Required"; + } + if (!trimmed.startsWith(ANTHROPIC_SETUP_TOKEN_PREFIX)) { + return `Expected token starting with ${ANTHROPIC_SETUP_TOKEN_PREFIX}`; + } + if (trimmed.length < ANTHROPIC_SETUP_TOKEN_MIN_LENGTH) { + return "Token looks too short; paste the full setup-token"; + } + return undefined; +} diff --git a/src/plugins/provider-auth-types.ts b/src/plugins/provider-auth-types.ts new file mode 100644 index 00000000000..c26ba4778d8 --- /dev/null +++ b/src/plugins/provider-auth-types.ts @@ -0,0 +1 @@ +export type SecretInputMode = "plaintext" | "ref"; // pragma: allowlist secret diff --git a/src/plugins/provider-model-allowlist.ts b/src/plugins/provider-model-allowlist.ts new file mode 100644 index 00000000000..bc6dfc5308d --- /dev/null +++ b/src/plugins/provider-model-allowlist.ts @@ -0,0 +1,41 @@ +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveAllowlistModelKey } from "../agents/model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; + +export function ensureModelAllowlistEntry(params: { + cfg: OpenClawConfig; + modelRef: string; + defaultProvider?: string; +}): OpenClawConfig { + const rawModelRef = params.modelRef.trim(); + if (!rawModelRef) { + return params.cfg; + } + + const models = { ...params.cfg.agents?.defaults?.models }; + const keySet = new Set([rawModelRef]); + const canonicalKey = resolveAllowlistModelKey( + rawModelRef, + params.defaultProvider ?? DEFAULT_PROVIDER, + ); + if (canonicalKey) { + keySet.add(canonicalKey); + } + + for (const key of keySet) { + models[key] = { + ...models[key], + }; + } + + return { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + models, + }, + }, + }; +} diff --git a/src/plugins/provider-model-defaults.ts b/src/plugins/provider-model-defaults.ts new file mode 100644 index 00000000000..60a18c1a759 --- /dev/null +++ b/src/plugins/provider-model-defaults.ts @@ -0,0 +1,81 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { ensureModelAllowlistEntry } from "./provider-model-allowlist.js"; +import { applyAgentDefaultPrimaryModel } from "./provider-model-primary.js"; + +export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; +export const OPENAI_DEFAULT_MODEL = "openai/gpt-5.1-codex"; +export const OPENCODE_GO_DEFAULT_MODEL_REF = "opencode-go/kimi-k2.5"; +export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode/claude-opus-4-6"; + +const LEGACY_OPENCODE_ZEN_DEFAULT_MODELS = new Set([ + "opencode/claude-opus-4-5", + "opencode-zen/claude-opus-4-5", +]); + +export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ cfg, model: GOOGLE_GEMINI_DEFAULT_MODEL }); +} + +export function applyOpenAIProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = ensureModelAllowlistEntry({ + cfg, + modelRef: OPENAI_DEFAULT_MODEL, + }); + const models = { ...next.agents?.defaults?.models }; + models[OPENAI_DEFAULT_MODEL] = { + ...models[OPENAI_DEFAULT_MODEL], + alias: models[OPENAI_DEFAULT_MODEL]?.alias ?? "GPT", + }; + + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpenAIConfig(cfg: OpenClawConfig): OpenClawConfig { + const next = applyOpenAIProviderConfig(cfg); + return { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + model: + next.agents?.defaults?.model && typeof next.agents.defaults.model === "object" + ? { + ...next.agents.defaults.model, + primary: OPENAI_DEFAULT_MODEL, + } + : { primary: OPENAI_DEFAULT_MODEL }, + }, + }, + }; +} + +export function applyOpencodeGoModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ cfg, model: OPENCODE_GO_DEFAULT_MODEL_REF }); +} + +export function applyOpencodeZenModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + return applyAgentDefaultPrimaryModel({ + cfg, + model: OPENCODE_ZEN_DEFAULT_MODEL, + legacyModels: LEGACY_OPENCODE_ZEN_DEFAULT_MODELS, + }); +} diff --git a/src/plugins/provider-model-primary.ts b/src/plugins/provider-model-primary.ts new file mode 100644 index 00000000000..bf4bd8a2fe7 --- /dev/null +++ b/src/plugins/provider-model-primary.ts @@ -0,0 +1,72 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { AgentModelListConfig } from "../config/types.js"; + +export function resolvePrimaryModel(model?: AgentModelListConfig | string): string | undefined { + if (typeof model === "string") { + return model; + } + if (model && typeof model === "object" && typeof model.primary === "string") { + return model.primary; + } + return undefined; +} + +export function applyAgentDefaultPrimaryModel(params: { + cfg: OpenClawConfig; + model: string; + legacyModels?: Set; +}): { next: OpenClawConfig; changed: boolean } { + const current = resolvePrimaryModel(params.cfg.agents?.defaults?.model)?.trim(); + const normalizedCurrent = current && params.legacyModels?.has(current) ? params.model : current; + if (normalizedCurrent === params.model) { + return { next: params.cfg, changed: false }; + } + + return { + next: { + ...params.cfg, + agents: { + ...params.cfg.agents, + defaults: { + ...params.cfg.agents?.defaults, + model: + params.cfg.agents?.defaults?.model && + typeof params.cfg.agents.defaults.model === "object" + ? { + ...params.cfg.agents.defaults.model, + primary: params.model, + } + : { primary: params.model }, + }, + }, + }, + changed: true, + }; +} + +export function applyPrimaryModel(cfg: OpenClawConfig, model: string): OpenClawConfig { + const defaults = cfg.agents?.defaults; + const existingModel = defaults?.model; + const existingModels = defaults?.models; + const fallbacks = + typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel + ? (existingModel as { fallbacks?: string[] }).fallbacks + : undefined; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + model: { + ...(fallbacks ? { fallbacks } : undefined), + primary: model, + }, + models: { + ...existingModels, + [model]: existingModels?.[model] ?? {}, + }, + }, + }, + }; +} diff --git a/src/plugins/provider-oauth-flow.ts b/src/plugins/provider-oauth-flow.ts new file mode 100644 index 00000000000..e2ae6717c60 --- /dev/null +++ b/src/plugins/provider-oauth-flow.ts @@ -0,0 +1,53 @@ +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +export type OAuthPrompt = { message: string; placeholder?: string }; + +const validateRequiredInput = (value: string) => (value.trim().length > 0 ? undefined : "Required"); + +export function createVpsAwareOAuthHandlers(params: { + isRemote: boolean; + prompter: WizardPrompter; + runtime: RuntimeEnv; + spin: ReturnType; + openUrl: (url: string) => Promise; + localBrowserMessage: string; + manualPromptMessage?: string; +}): { + onAuth: (event: { url: string }) => Promise; + onPrompt: (prompt: OAuthPrompt) => Promise; +} { + const manualPromptMessage = params.manualPromptMessage ?? "Paste the redirect URL"; + let manualCodePromise: Promise | undefined; + + return { + onAuth: async ({ url }) => { + if (params.isRemote) { + params.spin.stop("OAuth URL ready"); + params.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + manualCodePromise = params.prompter + .text({ + message: manualPromptMessage, + validate: validateRequiredInput, + }) + .then((value) => String(value)); + return; + } + + params.spin.update(params.localBrowserMessage); + await params.openUrl(url); + params.runtime.log(`Open: ${url}`); + }, + onPrompt: async (prompt) => { + if (manualCodePromise) { + return manualCodePromise; + } + const code = await params.prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: validateRequiredInput, + }); + return String(code); + }, + }; +} diff --git a/src/plugins/provider-openai-codex-oauth-tls.ts b/src/plugins/provider-openai-codex-oauth-tls.ts new file mode 100644 index 00000000000..bf9e69b0519 --- /dev/null +++ b/src/plugins/provider-openai-codex-oauth-tls.ts @@ -0,0 +1,164 @@ +import path from "node:path"; +import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { note } from "../terminal/note.js"; + +const TLS_CERT_ERROR_CODES = new Set([ + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_HAS_EXPIRED", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "ERR_TLS_CERT_ALTNAME_INVALID", +]); + +const TLS_CERT_ERROR_PATTERNS = [ + /unable to get local issuer certificate/i, + /unable to verify the first certificate/i, + /self[- ]signed certificate/i, + /certificate has expired/i, +]; + +const OPENAI_AUTH_PROBE_URL = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email"; + +type PreflightFailureKind = "tls-cert" | "network"; + +export type OpenAIOAuthTlsPreflightResult = + | { ok: true } + | { + ok: false; + kind: PreflightFailureKind; + code?: string; + message: string; + }; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function extractFailure(error: unknown): { + code?: string; + message: string; + kind: PreflightFailureKind; +} { + const root = asRecord(error); + const rootCause = asRecord(root?.cause); + const code = typeof rootCause?.code === "string" ? rootCause.code : undefined; + const message = + typeof rootCause?.message === "string" + ? rootCause.message + : typeof root?.message === "string" + ? root.message + : String(error); + const isTlsCertError = + (code ? TLS_CERT_ERROR_CODES.has(code) : false) || + TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); + return { + code, + message, + kind: isTlsCertError ? "tls-cert" : "network", + }; +} + +function resolveHomebrewPrefixFromExecPath(execPath: string): string | null { + const marker = `${path.sep}Cellar${path.sep}`; + const idx = execPath.indexOf(marker); + if (idx > 0) { + return execPath.slice(0, idx); + } + const envPrefix = process.env.HOMEBREW_PREFIX?.trim(); + return envPrefix ? envPrefix : null; +} + +function resolveCertBundlePath(): string | null { + const prefix = resolveHomebrewPrefixFromExecPath(process.execPath); + if (!prefix) { + return null; + } + return path.join(prefix, "etc", "openssl@3", "cert.pem"); +} + +function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean { + const profiles = cfg.auth?.profiles; + if (!profiles) { + return false; + } + return Object.values(profiles).some( + (profile) => profile.provider === "openai-codex" && profile.mode === "oauth", + ); +} + +function shouldRunOpenAIOAuthTlsPrerequisites(params: { + cfg: OpenClawConfig; + deep?: boolean; +}): boolean { + if (params.deep === true) { + return true; + } + return hasOpenAICodexOAuthProfile(params.cfg); +} + +export async function runOpenAIOAuthTlsPreflight(options?: { + timeoutMs?: number; + fetchImpl?: typeof fetch; +}): Promise { + const timeoutMs = options?.timeoutMs ?? 5000; + const fetchImpl = options?.fetchImpl ?? fetch; + try { + await fetchImpl(OPENAI_AUTH_PROBE_URL, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + return { ok: true }; + } catch (error) { + const failure = extractFailure(error); + return { + ok: false, + kind: failure.kind, + code: failure.code, + message: failure.message, + }; + } +} + +export function formatOpenAIOAuthTlsPreflightFix( + result: Exclude, +): string { + if (result.kind !== "tls-cert") { + return [ + "OpenAI OAuth prerequisites check failed due to a network error before the browser flow.", + `Cause: ${result.message}`, + "Verify DNS/firewall/proxy access to auth.openai.com and retry.", + ].join("\n"); + } + const certBundlePath = resolveCertBundlePath(); + const lines = [ + "OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.", + `Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`, + "", + "Fix (Homebrew Node/OpenSSL):", + `- ${formatCliCommand("brew postinstall ca-certificates")}`, + `- ${formatCliCommand("brew postinstall openssl@3")}`, + ]; + if (certBundlePath) { + lines.push(`- Verify cert bundle exists: ${certBundlePath}`); + } + lines.push("- Retry the OAuth login flow."); + return lines.join("\n"); +} + +export async function noteOpenAIOAuthTlsPrerequisites(params: { + cfg: OpenClawConfig; + deep?: boolean; +}): Promise { + if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) { + return; + } + const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 }); + if (result.ok || result.kind !== "tls-cert") { + return; + } + note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites"); +} diff --git a/src/plugins/provider-openai-codex-oauth.ts b/src/plugins/provider-openai-codex-oauth.ts new file mode 100644 index 00000000000..6e16cf863f0 --- /dev/null +++ b/src/plugins/provider-openai-codex-oauth.ts @@ -0,0 +1,65 @@ +import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; +import { + formatOpenAIOAuthTlsPreflightFix, + runOpenAIOAuthTlsPreflight, +} from "./provider-openai-codex-oauth-tls.js"; + +export async function loginOpenAICodexOAuth(params: { + prompter: WizardPrompter; + runtime: RuntimeEnv; + isRemote: boolean; + openUrl: (url: string) => Promise; + localBrowserMessage?: string; +}): Promise { + const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; + const preflight = await runOpenAIOAuthTlsPreflight(); + if (!preflight.ok && preflight.kind === "tls-cert") { + const hint = formatOpenAIOAuthTlsPreflightFix(preflight); + runtime.error(hint); + await prompter.note(hint, "OAuth prerequisites"); + throw new Error(preflight.message); + } + + await prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + ].join("\n") + : [ + "Browser will open for OpenAI authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "OpenAI OAuth uses localhost:1455 for the callback.", + ].join("\n"), + "OpenAI Codex OAuth", + ); + + const spin = prompter.progress("Starting OAuth flow…"); + try { + const { onAuth: baseOnAuth, onPrompt } = createVpsAwareOAuthHandlers({ + isRemote, + prompter, + runtime, + spin, + openUrl, + localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…", + }); + + const creds = await loginOpenAICodex({ + onAuth: baseOnAuth, + onPrompt, + onProgress: (msg: string) => spin.update(msg), + }); + spin.stop("OpenAI OAuth complete"); + return creds ?? null; + } catch (err) { + spin.stop("OpenAI OAuth failed"); + runtime.error(String(err)); + await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help"); + throw err; + } +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 23e761940df..52cb2787977 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -17,8 +17,6 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; -import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js"; -import type { OnboardOptions } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -39,11 +37,20 @@ import type { SpeechVoiceOption, } from "../tts/provider-types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import type { SecretInputMode } from "./provider-auth-types.js"; +import type { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; import type { PluginRuntime } from "./runtime/types.js"; export type { PluginRuntime } from "./runtime/types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; +export type ProviderAuthOptionBag = { + token?: string; + tokenProvider?: string; + secretInputMode?: SecretInputMode; + [key: string]: unknown; +}; + export type PluginLogger = { debug?: (message: string) => void; info: (message: string) => void; @@ -144,7 +151,7 @@ export type ProviderAuthContext = { * `--token/--token-provider` pairs. Direct `models auth login` usually * leaves this undefined. */ - opts?: Partial; + opts?: ProviderAuthOptionBag; /** * Onboarding secret persistence preference. * @@ -152,7 +159,7 @@ export type ProviderAuthContext = { * plaintext or env/file/exec ref storage. Ad-hoc `models auth login` flows * usually leave it undefined. */ - secretInputMode?: OnboardOptions["secretInputMode"]; + secretInputMode?: SecretInputMode; /** * Whether the provider auth flow should offer the onboarding secret-storage * mode picker when `secretInputMode` is unset. @@ -196,7 +203,7 @@ export type ProviderAuthMethodNonInteractiveContext = { authChoice: string; config: OpenClawConfig; baseConfig: OpenClawConfig; - opts: OnboardOptions; + opts: ProviderAuthOptionBag; runtime: RuntimeEnv; agentDir?: string; workspaceDir?: string; diff --git a/src/wizard/setup.gateway-config.ts b/src/wizard/setup.gateway-config.ts index 74420c1dac2..ae6c9e42c6f 100644 --- a/src/wizard/setup.gateway-config.ts +++ b/src/wizard/setup.gateway-config.ts @@ -1,7 +1,3 @@ -import { - promptSecretRefForSetup, - resolveSecretInputModeForEnvSelection, -} from "../commands/auth-choice.apply-helpers.js"; import { normalizeGatewayTokenInput, randomToken, @@ -23,6 +19,10 @@ import { } from "../gateway/gateway-config-prompts.shared.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; +import { + promptSecretRefForSetup, + resolveSecretInputModeForEnvSelection, +} from "../plugins/provider-auth-input.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import type { WizardPrompter } from "./prompts.js";