openclaw/src/plugins/provider-auth-helpers.ts

263 lines
7.7 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
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 { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
coerceSecretRef,
DEFAULT_SECRET_PROVIDER_ALIAS,
type SecretInput,
type SecretRef,
} 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_]*)\}$/;
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
export type ApiKeyStorageOptions = {
secretInputMode?: SecretInputMode;
};
export type WriteOAuthCredentialsOptions = {
syncSiblingAgents?: boolean;
};
function buildEnvSecretRef(id: string): SecretRef {
return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id };
}
function parseEnvSecretRef(value: string): SecretRef | null {
const match = ENV_REF_PATTERN.exec(value);
if (!match) {
return null;
}
return buildEnvSecretRef(match[1]);
}
function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef {
const envVars = PROVIDER_ENV_VARS[provider];
const envVar = envVars?.find((candidate) => candidate.trim().length > 0);
if (!envVar) {
throw new Error(
`Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`,
);
}
return buildEnvSecretRef(envVar);
}
function resolveApiKeySecretInput(
provider: string,
input: SecretInput,
options?: ApiKeyStorageOptions,
): SecretInput {
const coercedRef = coerceSecretRef(input);
if (coercedRef) {
return coercedRef;
}
const normalized = normalizeSecretInput(input);
const inlineEnvRef = parseEnvSecretRef(normalized);
if (inlineEnvRef) {
return inlineEnvRef;
}
if (options?.secretInputMode === "ref") {
return resolveProviderDefaultEnvSecretRef(provider);
}
return normalized;
}
export function buildApiKeyCredential(
provider: string,
input: SecretInput,
metadata?: Record<string, string>,
options?: ApiKeyStorageOptions,
): {
type: "api_key";
provider: string;
key?: string;
keyRef?: SecretRef;
metadata?: Record<string, string>;
} {
const secretInput = resolveApiKeySecretInput(provider, input, options);
if (typeof secretInput === "string") {
return {
type: "api_key",
provider,
key: secretInput,
...(metadata ? { metadata } : {}),
};
}
return {
type: "api_key",
provider,
keyRef: secretInput,
...(metadata ? { metadata } : {}),
};
}
export function applyAuthProfileConfig(
cfg: OpenClawConfig,
params: {
profileId: string;
provider: string;
mode: "api_key" | "oauth" | "token";
email?: string;
preferProfileFirst?: boolean;
},
): OpenClawConfig {
const normalizedProvider = normalizeProviderIdForAuth(params.provider);
const profiles = {
...cfg.auth?.profiles,
[params.profileId]: {
provider: params.provider,
mode: params.mode,
...(params.email ? { email: params.email } : {}),
},
};
const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {})
.filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === normalizedProvider)
.map(([profileId, profile]) => ({ profileId, mode: profile.mode }));
// Maintain `auth.order` when it already exists. Additionally, if we detect
// mixed auth modes for the same provider, keep the newly selected profile first.
const existingProviderOrder = cfg.auth?.order?.[params.provider];
const preferProfileFirst = params.preferProfileFirst ?? true;
const reorderedProviderOrder =
existingProviderOrder && preferProfileFirst
? [
params.profileId,
...existingProviderOrder.filter((profileId) => profileId !== params.profileId),
]
: existingProviderOrder;
const hasMixedConfiguredModes = configuredProviderProfiles.some(
({ profileId, mode }) => profileId !== params.profileId && mode !== params.mode,
);
const derivedProviderOrder =
existingProviderOrder === undefined && preferProfileFirst && hasMixedConfiguredModes
? [
params.profileId,
...configuredProviderProfiles
.map(({ profileId }) => profileId)
.filter((profileId) => profileId !== params.profileId),
]
: undefined;
const order =
existingProviderOrder !== undefined
? {
...cfg.auth?.order,
[params.provider]: reorderedProviderOrder?.includes(params.profileId)
? reorderedProviderOrder
: [...(reorderedProviderOrder ?? []), params.profileId],
}
: derivedProviderOrder
? {
...cfg.auth?.order,
[params.provider]: derivedProviderOrder,
}
: cfg.auth?.order;
return {
...cfg,
auth: {
...cfg.auth,
profiles,
...(order ? { order } : {}),
},
};
}
/** Resolve real path, returning null if the target doesn't exist. */
function safeRealpathSync(dir: string): string | null {
try {
return fs.realpathSync(path.resolve(dir));
} catch {
return null;
}
}
function resolveSiblingAgentDirs(primaryAgentDir: string): string[] {
const normalized = path.resolve(primaryAgentDir);
const parentOfAgent = path.dirname(normalized);
const candidateAgentsRoot = path.dirname(parentOfAgent);
const looksLikeStandardLayout =
path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents";
const agentsRoot = looksLikeStandardLayout
? candidateAgentsRoot
: path.join(resolveStateDir(), "agents");
const entries = (() => {
try {
return fs.readdirSync(agentsRoot, { withFileTypes: true });
} catch {
return [];
}
})();
const discovered = entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map((entry) => path.join(agentsRoot, entry.name, "agent"));
const seen = new Set<string>();
const result: string[] = [];
for (const dir of [normalized, ...discovered]) {
const real = safeRealpathSync(dir);
if (real && !seen.has(real)) {
seen.add(real);
result.push(real);
}
}
return result;
}
export async function writeOAuthCredentials(
provider: string,
creds: OAuthCredentials,
agentDir?: string,
options?: WriteOAuthCredentialsOptions,
): Promise<string> {
const email =
typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default";
const profileId = `${provider}:${email}`;
const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir));
const targetAgentDirs = options?.syncSiblingAgents
? resolveSiblingAgentDirs(resolvedAgentDir)
: [resolvedAgentDir];
const credential = {
type: "oauth" as const,
provider,
...creds,
};
upsertAuthProfile({
profileId,
credential,
agentDir: resolvedAgentDir,
});
if (options?.syncSiblingAgents) {
const primaryReal = safeRealpathSync(resolvedAgentDir);
for (const targetAgentDir of targetAgentDirs) {
const targetReal = safeRealpathSync(targetAgentDir);
if (targetReal && primaryReal && targetReal === primaryReal) {
continue;
}
try {
upsertAuthProfile({
profileId,
credential,
agentDir: targetAgentDir,
});
} catch {
// Best-effort: sibling sync failure must not block primary setup.
}
}
}
return profileId;
}