Files
moltbot/src/agents/model-auth.ts
Vincent Koc 42e3d8d693 Secrets: add inline allowlist review set (#38314)
* Secrets: add inline allowlist review set

* Secrets: narrow detect-secrets file exclusions

* Secrets: exclude Docker fingerprint false positive

* Secrets: allowlist test and docs false positives

* Secrets: refresh baseline after allowlist updates

* Secrets: fix gateway chat fixture pragma

* Secrets: format pre-commit config

* Android: keep talk mode fixture JSON valid

* Feishu: rely on client timeout injection

* Secrets: allowlist provider auth test fixtures

* Secrets: allowlist onboard search fixtures

* Secrets: allowlist onboard mode fixture

* Secrets: allowlist gateway auth mode fixture

* Secrets: allowlist APNS wake test key

* Secrets: allowlist gateway reload fixtures

* Secrets: allowlist moonshot video fixture

* Secrets: allowlist auto audio fixture

* Secrets: allowlist tiny audio fixture

* Secrets: allowlist embeddings fixtures

* Secrets: allowlist resolve fixtures

* Secrets: allowlist target registry pattern fixtures

* Secrets: allowlist gateway chat env fixture

* Secrets: refresh baseline after fixture allowlists

* Secrets: reapply gateway chat env allowlist

* Secrets: reapply gateway chat env allowlist

* Secrets: stabilize gateway chat env allowlist

* Secrets: allowlist runtime snapshot save fixture

* Secrets: allowlist oauth profile fixtures

* Secrets: allowlist compaction identifier fixture

* Secrets: allowlist model auth fixture

* Secrets: allowlist model status fixtures

* Secrets: allowlist custom onboarding fixture

* Secrets: allowlist mattermost token summary fixtures

* Secrets: allowlist gateway auth suite fixtures

* Secrets: allowlist channel summary fixture

* Secrets: allowlist provider usage auth fixtures

* Secrets: allowlist media proxy fixture

* Secrets: allowlist secrets audit fixtures

* Secrets: refresh baseline after final fixture allowlists

* Feishu: prefer explicit client timeout

* Feishu: test direct timeout precedence
2026-03-06 19:35:26 -05:00

449 lines
13 KiB
TypeScript

import path from "node:path";
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import {
normalizeOptionalSecretInput,
normalizeSecretInput,
} from "../utils/normalize-secret-input.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
listProfilesForProvider,
resolveApiKeyForProfile,
resolveAuthProfileOrder,
resolveAuthStorePathForDisplay,
} from "./auth-profiles.js";
import { normalizeProviderId } from "./model-selection.js";
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK";
const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID";
const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY";
const AWS_PROFILE_ENV = "AWS_PROFILE";
function resolveProviderConfig(
cfg: OpenClawConfig | undefined,
provider: string,
): ModelProviderConfig | undefined {
const providers = cfg?.models?.providers ?? {};
const direct = providers[provider] as ModelProviderConfig | undefined;
if (direct) {
return direct;
}
const normalized = normalizeProviderId(provider);
if (normalized === provider) {
const matched = Object.entries(providers).find(
([key]) => normalizeProviderId(key) === normalized,
);
return matched?.[1];
}
return (
(providers[normalized] as ModelProviderConfig | undefined) ??
Object.entries(providers).find(([key]) => normalizeProviderId(key) === normalized)?.[1]
);
}
export function getCustomProviderApiKey(
cfg: OpenClawConfig | undefined,
provider: string,
): string | undefined {
const entry = resolveProviderConfig(cfg, provider);
return normalizeOptionalSecretInput(entry?.apiKey);
}
function resolveProviderAuthOverride(
cfg: OpenClawConfig | undefined,
provider: string,
): ModelProviderAuthMode | undefined {
const entry = resolveProviderConfig(cfg, provider);
const auth = entry?.auth;
if (auth === "api-key" || auth === "aws-sdk" || auth === "oauth" || auth === "token") {
return auth;
}
return undefined;
}
function resolveSyntheticLocalProviderAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
}): ResolvedProviderAuth | null {
const normalizedProvider = normalizeProviderId(params.provider);
if (normalizedProvider !== "ollama") {
return null;
}
const providerConfig = resolveProviderConfig(params.cfg, params.provider);
if (!providerConfig) {
return null;
}
const hasApiConfig =
Boolean(providerConfig.api?.trim()) ||
Boolean(providerConfig.baseUrl?.trim()) ||
(Array.isArray(providerConfig.models) && providerConfig.models.length > 0);
if (!hasApiConfig) {
return null;
}
return {
apiKey: "ollama-local", // pragma: allowlist secret
source: "models.providers.ollama (synthetic local key)",
mode: "api-key",
};
}
function resolveEnvSourceLabel(params: {
applied: Set<string>;
envVars: string[];
label: string;
}): string {
const shellApplied = params.envVars.some((envVar) => params.applied.has(envVar));
const prefix = shellApplied ? "shell env: " : "env: ";
return `${prefix}${params.label}`;
}
export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): string | undefined {
if (env[AWS_BEARER_ENV]?.trim()) {
return AWS_BEARER_ENV;
}
if (env[AWS_ACCESS_KEY_ENV]?.trim() && env[AWS_SECRET_KEY_ENV]?.trim()) {
return AWS_ACCESS_KEY_ENV;
}
if (env[AWS_PROFILE_ENV]?.trim()) {
return AWS_PROFILE_ENV;
}
return undefined;
}
function resolveAwsSdkAuthInfo(): { mode: "aws-sdk"; source: string } {
const applied = new Set(getShellEnvAppliedKeys());
if (process.env[AWS_BEARER_ENV]?.trim()) {
return {
mode: "aws-sdk",
source: resolveEnvSourceLabel({
applied,
envVars: [AWS_BEARER_ENV],
label: AWS_BEARER_ENV,
}),
};
}
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
return {
mode: "aws-sdk",
source: resolveEnvSourceLabel({
applied,
envVars: [AWS_ACCESS_KEY_ENV, AWS_SECRET_KEY_ENV],
label: `${AWS_ACCESS_KEY_ENV} + ${AWS_SECRET_KEY_ENV}`,
}),
};
}
if (process.env[AWS_PROFILE_ENV]?.trim()) {
return {
mode: "aws-sdk",
source: resolveEnvSourceLabel({
applied,
envVars: [AWS_PROFILE_ENV],
label: AWS_PROFILE_ENV,
}),
};
}
return { mode: "aws-sdk", source: "aws-sdk default chain" };
}
export type ResolvedProviderAuth = {
apiKey?: string;
profileId?: string;
source: string;
mode: "api-key" | "oauth" | "token" | "aws-sdk";
};
export async function resolveApiKeyForProvider(params: {
provider: string;
cfg?: OpenClawConfig;
profileId?: string;
preferredProfile?: string;
store?: AuthProfileStore;
agentDir?: string;
}): Promise<ResolvedProviderAuth> {
const { provider, cfg, profileId, preferredProfile } = params;
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
if (profileId) {
const resolved = await resolveApiKeyForProfile({
cfg,
store,
profileId,
agentDir: params.agentDir,
});
if (!resolved) {
throw new Error(`No credentials found for profile "${profileId}".`);
}
const mode = store.profiles[profileId]?.type;
return {
apiKey: resolved.apiKey,
profileId,
source: `profile:${profileId}`,
mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key",
};
}
const authOverride = resolveProviderAuthOverride(cfg, provider);
if (authOverride === "aws-sdk") {
return resolveAwsSdkAuthInfo();
}
const order = resolveAuthProfileOrder({
cfg,
store,
provider,
preferredProfile,
});
for (const candidate of order) {
try {
const resolved = await resolveApiKeyForProfile({
cfg,
store,
profileId: candidate,
agentDir: params.agentDir,
});
if (resolved) {
const mode = store.profiles[candidate]?.type;
return {
apiKey: resolved.apiKey,
profileId: candidate,
source: `profile:${candidate}`,
mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key",
};
}
} catch {}
}
const envResolved = resolveEnvApiKey(provider);
if (envResolved) {
return {
apiKey: envResolved.apiKey,
source: envResolved.source,
mode: envResolved.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key",
};
}
const customKey = getCustomProviderApiKey(cfg, provider);
if (customKey) {
return { apiKey: customKey, source: "models.json", mode: "api-key" };
}
const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider });
if (syntheticLocalAuth) {
return syntheticLocalAuth;
}
const normalized = normalizeProviderId(provider);
if (authOverride === undefined && normalized === "amazon-bedrock") {
return resolveAwsSdkAuthInfo();
}
if (provider === "openai") {
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
if (hasCodex) {
throw new Error(
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.',
);
}
}
const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);
const resolvedAgentDir = path.dirname(authStorePath);
throw new Error(
[
`No API key found for provider "${provider}".`,
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
`Configure auth for this agent (${formatCliCommand("openclaw agents add <id>")}) or copy auth-profiles.json from the main agentDir.`,
].join(" "),
);
}
export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const normalized = normalizeProviderId(provider);
const applied = new Set(getShellEnvAppliedKeys());
const pick = (envVar: string): EnvApiKeyResult | null => {
const value = normalizeOptionalSecretInput(process.env[envVar]);
if (!value) {
return null;
}
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
return { apiKey: value, source };
};
if (normalized === "github-copilot") {
return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN");
}
if (normalized === "anthropic") {
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
}
if (normalized === "chutes") {
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
}
if (normalized === "zai") {
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
}
if (normalized === "google-vertex") {
const envKey = getEnvApiKey(normalized);
if (!envKey) {
return null;
}
return { apiKey: envKey, source: "gcloud adc" };
}
if (normalized === "opencode") {
return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY");
}
if (normalized === "qwen-portal") {
return pick("QWEN_OAUTH_TOKEN") ?? pick("QWEN_PORTAL_API_KEY");
}
if (normalized === "volcengine" || normalized === "volcengine-plan") {
return pick("VOLCANO_ENGINE_API_KEY");
}
if (normalized === "byteplus" || normalized === "byteplus-plan") {
return pick("BYTEPLUS_API_KEY");
}
if (normalized === "minimax-portal") {
return pick("MINIMAX_OAUTH_TOKEN") ?? pick("MINIMAX_API_KEY");
}
if (normalized === "kimi-coding") {
return pick("KIMI_API_KEY") ?? pick("KIMICODE_API_KEY");
}
if (normalized === "huggingface") {
return pick("HUGGINGFACE_HUB_TOKEN") ?? pick("HF_TOKEN");
}
const envMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
google: "GEMINI_API_KEY",
voyage: "VOYAGE_API_KEY",
groq: "GROQ_API_KEY",
deepgram: "DEEPGRAM_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
litellm: "LITELLM_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
"cloudflare-ai-gateway": "CLOUDFLARE_AI_GATEWAY_API_KEY",
moonshot: "MOONSHOT_API_KEY",
minimax: "MINIMAX_API_KEY",
nvidia: "NVIDIA_API_KEY",
xiaomi: "XIAOMI_API_KEY",
synthetic: "SYNTHETIC_API_KEY",
venice: "VENICE_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
together: "TOGETHER_API_KEY",
qianfan: "QIANFAN_API_KEY",
ollama: "OLLAMA_API_KEY",
vllm: "VLLM_API_KEY",
kilocode: "KILOCODE_API_KEY",
};
const envVar = envMap[normalized];
if (!envVar) {
return null;
}
return pick(envVar);
}
export function resolveModelAuthMode(
provider?: string,
cfg?: OpenClawConfig,
store?: AuthProfileStore,
): ModelAuthMode | undefined {
const resolved = provider?.trim();
if (!resolved) {
return undefined;
}
const authOverride = resolveProviderAuthOverride(cfg, resolved);
if (authOverride === "aws-sdk") {
return "aws-sdk";
}
const authStore = store ?? ensureAuthProfileStore();
const profiles = listProfilesForProvider(authStore, resolved);
if (profiles.length > 0) {
const modes = new Set(
profiles
.map((id) => authStore.profiles[id]?.type)
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
);
const distinct = ["oauth", "token", "api_key"].filter((k) =>
modes.has(k as "oauth" | "token" | "api_key"),
);
if (distinct.length >= 2) {
return "mixed";
}
if (modes.has("oauth")) {
return "oauth";
}
if (modes.has("token")) {
return "token";
}
if (modes.has("api_key")) {
return "api-key";
}
}
if (authOverride === undefined && normalizeProviderId(resolved) === "amazon-bedrock") {
return "aws-sdk";
}
const envKey = resolveEnvApiKey(resolved);
if (envKey?.apiKey) {
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
}
if (getCustomProviderApiKey(cfg, resolved)) {
return "api-key";
}
return "unknown";
}
export async function getApiKeyForModel(params: {
model: Model<Api>;
cfg?: OpenClawConfig;
profileId?: string;
preferredProfile?: string;
store?: AuthProfileStore;
agentDir?: string;
}): Promise<ResolvedProviderAuth> {
return resolveApiKeyForProvider({
provider: params.model.provider,
cfg: params.cfg,
profileId: params.profileId,
preferredProfile: params.preferredProfile,
store: params.store,
agentDir: params.agentDir,
});
}
export function requireApiKey(auth: ResolvedProviderAuth, provider: string): string {
const key = normalizeSecretInput(auth.apiKey);
if (key) {
return key;
}
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`);
}