mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-21 14:11:26 +00:00
refactor: isolate provider sdk auth and model helpers
This commit is contained in:
496
src/plugins/provider-auth-input.ts
Normal file
496
src/plugins/provider-auth-input.ts
Normal file
@@ -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<SecretRefChoice>({
|
||||
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<string>({
|
||||
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<SecretInputMode> {
|
||||
if (params.explicitMode) {
|
||||
return params.explicitMode;
|
||||
}
|
||||
if (typeof params.prompter.select !== "function") {
|
||||
return "plaintext";
|
||||
}
|
||||
const selected = await params.prompter.select<SecretInputMode>({
|
||||
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<void>;
|
||||
}): Promise<string | undefined> {
|
||||
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<void>;
|
||||
noteMessage?: string;
|
||||
noteTitle?: string;
|
||||
}): Promise<string> {
|
||||
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<void>;
|
||||
}): Promise<string> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user