openclaw/src/secrets/resolve.ts

960 lines
28 KiB
TypeScript

import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import type {
ExecSecretProviderConfig,
FileSecretProviderConfig,
SecretProviderConfig,
SecretRef,
SecretRefSource,
} from "../config/types.secrets.js";
import { inspectPathPermissions, safeStat } from "../security/audit-fs.js";
import { isPathInside } from "../security/scan-paths.js";
import { resolveUserPath } from "../utils.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { readJsonPointer } from "./json-pointer.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
SINGLE_VALUE_FILE_REF_ID,
resolveDefaultSecretProviderAlias,
secretRefKey,
} from "./ref-contract.js";
import {
describeUnknownError,
isNonEmptyString,
isRecord,
normalizePositiveInt,
} from "./shared.js";
const DEFAULT_PROVIDER_CONCURRENCY = 4;
const DEFAULT_MAX_REFS_PER_PROVIDER = 512;
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
const DEFAULT_FILE_MAX_BYTES = 1024 * 1024;
const DEFAULT_FILE_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024;
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
export type SecretRefResolveCache = {
resolvedByRefKey?: Map<string, Promise<unknown>>;
filePayloadByProvider?: Map<string, Promise<unknown>>;
};
type ResolveSecretRefOptions = {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
cache?: SecretRefResolveCache;
};
type ResolutionLimits = {
maxProviderConcurrency: number;
maxRefsPerProvider: number;
maxBatchBytes: number;
};
type ProviderResolutionOutput = Map<string, unknown>;
export class SecretProviderResolutionError extends Error {
readonly scope = "provider" as const;
readonly source: SecretRefSource;
readonly provider: string;
constructor(params: {
source: SecretRefSource;
provider: string;
message: string;
cause?: unknown;
}) {
super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined);
this.name = "SecretProviderResolutionError";
this.source = params.source;
this.provider = params.provider;
}
}
export class SecretRefResolutionError extends Error {
readonly scope = "ref" as const;
readonly source: SecretRefSource;
readonly provider: string;
readonly refId: string;
constructor(params: {
source: SecretRefSource;
provider: string;
refId: string;
message: string;
cause?: unknown;
}) {
super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined);
this.name = "SecretRefResolutionError";
this.source = params.source;
this.provider = params.provider;
this.refId = params.refId;
}
}
export function isProviderScopedSecretResolutionError(
value: unknown,
): value is SecretProviderResolutionError {
return value instanceof SecretProviderResolutionError;
}
function isSecretResolutionError(
value: unknown,
): value is SecretProviderResolutionError | SecretRefResolutionError {
return (
value instanceof SecretProviderResolutionError || value instanceof SecretRefResolutionError
);
}
function providerResolutionError(params: {
source: SecretRefSource;
provider: string;
message: string;
cause?: unknown;
}): SecretProviderResolutionError {
return new SecretProviderResolutionError(params);
}
function refResolutionError(params: {
source: SecretRefSource;
provider: string;
refId: string;
message: string;
cause?: unknown;
}): SecretRefResolutionError {
return new SecretRefResolutionError(params);
}
function throwUnknownProviderResolutionError(params: {
source: SecretRefSource;
provider: string;
err: unknown;
}): never {
if (isSecretResolutionError(params.err)) {
throw params.err;
}
throw providerResolutionError({
source: params.source,
provider: params.provider,
message: describeUnknownError(params.err),
cause: params.err,
});
}
async function readFileStatOrThrow(pathname: string, label: string) {
const stat = await safeStat(pathname);
if (!stat.ok) {
throw new Error(`${label} is not readable: ${pathname}`);
}
if (stat.isDir) {
throw new Error(`${label} must be a file: ${pathname}`);
}
return stat;
}
function isAbsolutePathname(value: string): boolean {
return (
path.isAbsolute(value) ||
WINDOWS_ABS_PATH_PATTERN.test(value) ||
WINDOWS_UNC_PATH_PATTERN.test(value)
);
}
function resolveResolutionLimits(config: OpenClawConfig): ResolutionLimits {
const resolution = config.secrets?.resolution;
return {
maxProviderConcurrency: normalizePositiveInt(
resolution?.maxProviderConcurrency,
DEFAULT_PROVIDER_CONCURRENCY,
),
maxRefsPerProvider: normalizePositiveInt(
resolution?.maxRefsPerProvider,
DEFAULT_MAX_REFS_PER_PROVIDER,
),
maxBatchBytes: normalizePositiveInt(resolution?.maxBatchBytes, DEFAULT_MAX_BATCH_BYTES),
};
}
function toProviderKey(source: SecretRefSource, provider: string): string {
return `${source}:${provider}`;
}
function resolveConfiguredProvider(ref: SecretRef, config: OpenClawConfig): SecretProviderConfig {
const providerConfig = config.secrets?.providers?.[ref.provider];
if (!providerConfig) {
if (ref.source === "env" && ref.provider === resolveDefaultSecretProviderAlias(config, "env")) {
return { source: "env" };
}
throw providerResolutionError({
source: ref.source,
provider: ref.provider,
message: `Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`,
});
}
if (providerConfig.source !== ref.source) {
throw providerResolutionError({
source: ref.source,
provider: ref.provider,
message: `Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`,
});
}
return providerConfig;
}
async function assertSecurePath(params: {
targetPath: string;
label: string;
trustedDirs?: string[];
allowInsecurePath?: boolean;
allowReadableByOthers?: boolean;
allowSymlinkPath?: boolean;
}): Promise<string> {
if (!isAbsolutePathname(params.targetPath)) {
throw new Error(`${params.label} must be an absolute path.`);
}
let effectivePath = params.targetPath;
let stat = await readFileStatOrThrow(effectivePath, params.label);
if (stat.isSymlink) {
if (!params.allowSymlinkPath) {
throw new Error(`${params.label} must not be a symlink: ${effectivePath}`);
}
try {
effectivePath = await fs.realpath(effectivePath);
} catch {
throw new Error(`${params.label} symlink target is not readable: ${params.targetPath}`);
}
if (!isAbsolutePathname(effectivePath)) {
throw new Error(`${params.label} resolved symlink target must be an absolute path.`);
}
stat = await readFileStatOrThrow(effectivePath, params.label);
if (stat.isSymlink) {
throw new Error(`${params.label} symlink target must not be a symlink: ${effectivePath}`);
}
}
if (params.trustedDirs && params.trustedDirs.length > 0) {
const trusted = params.trustedDirs.map((entry) => resolveUserPath(entry));
const inTrustedDir = trusted.some((dir) => isPathInside(dir, effectivePath));
if (!inTrustedDir) {
throw new Error(`${params.label} is outside trustedDirs: ${effectivePath}`);
}
}
if (params.allowInsecurePath) {
return effectivePath;
}
const perms = await inspectPathPermissions(effectivePath);
if (!perms.ok) {
throw new Error(`${params.label} permissions could not be verified: ${effectivePath}`);
}
const writableByOthers = perms.worldWritable || perms.groupWritable;
const readableByOthers = perms.worldReadable || perms.groupReadable;
if (writableByOthers || (!params.allowReadableByOthers && readableByOthers)) {
throw new Error(`${params.label} permissions are too open: ${effectivePath}`);
}
if (process.platform === "win32" && perms.source === "unknown") {
throw new Error(
`${params.label} ACL verification unavailable on Windows for ${effectivePath}. Set allowInsecurePath=true for this provider to bypass this check when the path is trusted.`,
);
}
if (process.platform !== "win32" && typeof process.getuid === "function" && stat.uid != null) {
const uid = process.getuid();
if (stat.uid !== uid) {
throw new Error(
`${params.label} must be owned by the current user (uid=${uid}): ${effectivePath}`,
);
}
}
return effectivePath;
}
async function readFileProviderPayload(params: {
providerName: string;
providerConfig: FileSecretProviderConfig;
cache?: SecretRefResolveCache;
}): Promise<unknown> {
const cacheKey = params.providerName;
const cache = params.cache;
if (cache?.filePayloadByProvider?.has(cacheKey)) {
return await (cache.filePayloadByProvider.get(cacheKey) as Promise<unknown>);
}
const filePath = resolveUserPath(params.providerConfig.path);
const readPromise = (async () => {
const secureFilePath = await assertSecurePath({
targetPath: filePath,
label: `secrets.providers.${params.providerName}.path`,
});
const timeoutMs = normalizePositiveInt(
params.providerConfig.timeoutMs,
DEFAULT_FILE_TIMEOUT_MS,
);
const maxBytes = normalizePositiveInt(params.providerConfig.maxBytes, DEFAULT_FILE_MAX_BYTES);
const abortController = new AbortController();
const timeoutErrorMessage = `File provider "${params.providerName}" timed out after ${timeoutMs}ms.`;
let timeoutHandle: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutHandle = setTimeout(() => {
abortController.abort();
reject(new Error(timeoutErrorMessage));
}, timeoutMs);
});
try {
const payload = await Promise.race([
fs.readFile(secureFilePath, { signal: abortController.signal }),
timeoutPromise,
]);
if (payload.byteLength > maxBytes) {
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
}
const text = payload.toString("utf8");
if (params.providerConfig.mode === "singleValue") {
return text.replace(/\r?\n$/, "");
}
const parsed = JSON.parse(text) as unknown;
if (!isRecord(parsed)) {
throw new Error(`File provider "${params.providerName}" payload is not a JSON object.`);
}
return parsed;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(timeoutErrorMessage, { cause: error });
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
})();
if (cache) {
cache.filePayloadByProvider ??= new Map();
cache.filePayloadByProvider.set(cacheKey, readPromise);
}
return await readPromise;
}
async function resolveEnvRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: Extract<SecretProviderConfig, { source: "env" }>;
env: NodeJS.ProcessEnv;
}): Promise<ProviderResolutionOutput> {
const resolved = new Map<string, unknown>();
const allowlist = params.providerConfig.allowlist
? new Set(params.providerConfig.allowlist)
: null;
for (const ref of params.refs) {
if (allowlist && !allowlist.has(ref.id)) {
throw refResolutionError({
source: "env",
provider: params.providerName,
refId: ref.id,
message: `Environment variable "${ref.id}" is not allowlisted in secrets.providers.${params.providerName}.allowlist.`,
});
}
const envValue = params.env[ref.id];
if (!isNonEmptyString(envValue)) {
throw refResolutionError({
source: "env",
provider: params.providerName,
refId: ref.id,
message: `Environment variable "${ref.id}" is missing or empty.`,
});
}
resolved.set(ref.id, envValue);
}
return resolved;
}
async function resolveFileRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: FileSecretProviderConfig;
cache?: SecretRefResolveCache;
}): Promise<ProviderResolutionOutput> {
let payload: unknown;
try {
payload = await readFileProviderPayload({
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.cache,
});
} catch (err) {
throwUnknownProviderResolutionError({
source: "file",
provider: params.providerName,
err,
});
}
const mode = params.providerConfig.mode ?? "json";
const resolved = new Map<string, unknown>();
if (mode === "singleValue") {
for (const ref of params.refs) {
if (ref.id !== SINGLE_VALUE_FILE_REF_ID) {
throw refResolutionError({
source: "file",
provider: params.providerName,
refId: ref.id,
message: `singleValue file provider "${params.providerName}" expects ref id "${SINGLE_VALUE_FILE_REF_ID}".`,
});
}
resolved.set(ref.id, payload);
}
return resolved;
}
for (const ref of params.refs) {
try {
resolved.set(ref.id, readJsonPointer(payload, ref.id, { onMissing: "throw" }));
} catch (err) {
throw refResolutionError({
source: "file",
provider: params.providerName,
refId: ref.id,
message: describeUnknownError(err),
cause: err,
});
}
}
return resolved;
}
type ExecRunResult = {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
termination: "exit" | "timeout" | "no-output-timeout";
};
function isIgnorableStdinWriteError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const code = String(error.code);
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
}
async function runExecResolver(params: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
input: string;
timeoutMs: number;
noOutputTimeoutMs: number;
maxOutputBytes: number;
}): Promise<ExecRunResult> {
return await new Promise((resolve, reject) => {
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
windowsHide: true,
});
let settled = false;
let stdout = "";
let stderr = "";
let timedOut = false;
let noOutputTimedOut = false;
let outputBytes = 0;
let noOutputTimer: NodeJS.Timeout | null = null;
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs);
const clearTimers = () => {
clearTimeout(timeoutTimer);
if (noOutputTimer) {
clearTimeout(noOutputTimer);
noOutputTimer = null;
}
};
const armNoOutputTimer = () => {
if (noOutputTimer) {
clearTimeout(noOutputTimer);
}
noOutputTimer = setTimeout(() => {
noOutputTimedOut = true;
child.kill("SIGKILL");
}, params.noOutputTimeoutMs);
};
const append = (chunk: Buffer | string, target: "stdout" | "stderr") => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
outputBytes += Buffer.byteLength(text, "utf8");
if (outputBytes > params.maxOutputBytes) {
child.kill("SIGKILL");
if (!settled) {
settled = true;
clearTimers();
reject(
new Error(`Exec provider output exceeded maxOutputBytes (${params.maxOutputBytes}).`),
);
}
return;
}
if (target === "stdout") {
stdout += text;
} else {
stderr += text;
}
armNoOutputTimer();
};
armNoOutputTimer();
child.on("error", (error) => {
if (settled) {
return;
}
settled = true;
clearTimers();
reject(error);
});
child.stdout?.on("data", (chunk) => append(chunk, "stdout"));
child.stderr?.on("data", (chunk) => append(chunk, "stderr"));
child.on("close", (code, signal) => {
if (settled) {
return;
}
settled = true;
clearTimers();
resolve({
stdout,
stderr,
code,
signal,
termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit",
});
});
const handleStdinError = (error: unknown) => {
if (isIgnorableStdinWriteError(error) || settled) {
return;
}
settled = true;
clearTimers();
reject(error instanceof Error ? error : new Error(String(error)));
};
child.stdin?.on("error", handleStdinError);
try {
child.stdin?.end(params.input);
} catch (error) {
handleStdinError(error);
}
});
}
function parseExecValues(params: {
providerName: string;
ids: string[];
stdout: string;
jsonOnly: boolean;
}): Record<string, unknown> {
const trimmed = params.stdout.trim();
if (!trimmed) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" returned empty stdout.`,
});
}
let parsed: unknown;
if (!params.jsonOnly && params.ids.length === 1) {
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
return { [params.ids[0]]: trimmed };
}
} else {
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" returned invalid JSON.`,
});
}
}
if (!isRecord(parsed)) {
if (!params.jsonOnly && params.ids.length === 1 && typeof parsed === "string") {
return { [params.ids[0]]: parsed };
}
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" response must be an object.`,
});
}
if (parsed.protocolVersion !== 1) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" protocolVersion must be 1.`,
});
}
const responseValues = parsed.values;
if (!isRecord(responseValues)) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" response missing "values".`,
});
}
const responseErrors = isRecord(parsed.errors) ? parsed.errors : null;
const out: Record<string, unknown> = {};
for (const id of params.ids) {
if (responseErrors && id in responseErrors) {
const entry = responseErrors[id];
if (isRecord(entry) && typeof entry.message === "string" && entry.message.trim()) {
throw refResolutionError({
source: "exec",
provider: params.providerName,
refId: id,
message: `Exec provider "${params.providerName}" failed for id "${id}" (${entry.message.trim()}).`,
});
}
throw refResolutionError({
source: "exec",
provider: params.providerName,
refId: id,
message: `Exec provider "${params.providerName}" failed for id "${id}".`,
});
}
if (!(id in responseValues)) {
throw refResolutionError({
source: "exec",
provider: params.providerName,
refId: id,
message: `Exec provider "${params.providerName}" response missing id "${id}".`,
});
}
out[id] = responseValues[id];
}
return out;
}
async function resolveExecRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: ExecSecretProviderConfig;
env: NodeJS.ProcessEnv;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
const ids = [...new Set(params.refs.map((ref) => ref.id))];
if (ids.length > params.limits.maxRefsPerProvider) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" exceeded maxRefsPerProvider (${params.limits.maxRefsPerProvider}).`,
});
}
const commandPath = resolveUserPath(params.providerConfig.command);
let secureCommandPath: string;
try {
secureCommandPath = await assertSecurePath({
targetPath: commandPath,
label: `secrets.providers.${params.providerName}.command`,
trustedDirs: params.providerConfig.trustedDirs,
allowInsecurePath: params.providerConfig.allowInsecurePath,
allowReadableByOthers: true,
allowSymlinkPath: params.providerConfig.allowSymlinkCommand,
});
} catch (err) {
throwUnknownProviderResolutionError({
source: "exec",
provider: params.providerName,
err,
});
}
const requestPayload = {
protocolVersion: 1,
provider: params.providerName,
ids,
};
const input = JSON.stringify(requestPayload);
if (Buffer.byteLength(input, "utf8") > params.limits.maxBatchBytes) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" request exceeded maxBatchBytes (${params.limits.maxBatchBytes}).`,
});
}
const childEnv: NodeJS.ProcessEnv = {};
for (const key of params.providerConfig.passEnv ?? []) {
const value = params.env[key];
if (value !== undefined) {
childEnv[key] = value;
}
}
for (const [key, value] of Object.entries(params.providerConfig.env ?? {})) {
childEnv[key] = value;
}
const timeoutMs = normalizePositiveInt(params.providerConfig.timeoutMs, DEFAULT_EXEC_TIMEOUT_MS);
const noOutputTimeoutMs = normalizePositiveInt(
params.providerConfig.noOutputTimeoutMs,
timeoutMs,
);
const maxOutputBytes = normalizePositiveInt(
params.providerConfig.maxOutputBytes,
DEFAULT_EXEC_MAX_OUTPUT_BYTES,
);
const jsonOnly = params.providerConfig.jsonOnly ?? true;
let result: ExecRunResult;
try {
result = await runExecResolver({
command: secureCommandPath,
args: params.providerConfig.args ?? [],
cwd: path.dirname(secureCommandPath),
env: childEnv,
input,
timeoutMs,
noOutputTimeoutMs,
maxOutputBytes,
});
} catch (err) {
throwUnknownProviderResolutionError({
source: "exec",
provider: params.providerName,
err,
});
}
if (result.termination === "timeout") {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" timed out after ${timeoutMs}ms.`,
});
}
if (result.termination === "no-output-timeout") {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" produced no output for ${noOutputTimeoutMs}ms.`,
});
}
if (result.code !== 0) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" exited with code ${String(result.code)}.`,
});
}
let values: Record<string, unknown>;
try {
values = parseExecValues({
providerName: params.providerName,
ids,
stdout: result.stdout,
jsonOnly,
});
} catch (err) {
throwUnknownProviderResolutionError({
source: "exec",
provider: params.providerName,
err,
});
}
const resolved = new Map<string, unknown>();
for (const id of ids) {
resolved.set(id, values[id]);
}
return resolved;
}
async function resolveProviderRefs(params: {
refs: SecretRef[];
source: SecretRefSource;
providerName: string;
providerConfig: SecretProviderConfig;
options: ResolveSecretRefOptions;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
try {
if (params.providerConfig.source === "env") {
return await resolveEnvRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.options.env ?? process.env,
});
}
if (params.providerConfig.source === "file") {
return await resolveFileRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.options.cache,
});
}
if (params.providerConfig.source === "exec") {
return await resolveExecRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.options.env ?? process.env,
limits: params.limits,
});
}
throw providerResolutionError({
source: params.source,
provider: params.providerName,
message: `Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`,
});
} catch (err) {
throwUnknownProviderResolutionError({
source: params.source,
provider: params.providerName,
err,
});
}
}
export async function resolveSecretRefValues(
refs: SecretRef[],
options: ResolveSecretRefOptions,
): Promise<Map<string, unknown>> {
if (refs.length === 0) {
return new Map();
}
const limits = resolveResolutionLimits(options.config);
const uniqueRefs = new Map<string, SecretRef>();
for (const ref of refs) {
const id = ref.id.trim();
if (!id) {
throw new Error("Secret reference id is empty.");
}
if (ref.source === "exec" && !isValidExecSecretRefId(id)) {
throw new Error(
`${formatExecSecretRefIdValidationMessage()} (ref: ${ref.source}:${ref.provider}:${id}).`,
);
}
uniqueRefs.set(secretRefKey(ref), { ...ref, id });
}
const grouped = new Map<
string,
{ source: SecretRefSource; providerName: string; refs: SecretRef[] }
>();
for (const ref of uniqueRefs.values()) {
const key = toProviderKey(ref.source, ref.provider);
const existing = grouped.get(key);
if (existing) {
existing.refs.push(ref);
continue;
}
grouped.set(key, { source: ref.source, providerName: ref.provider, refs: [ref] });
}
const tasks = [...grouped.values()].map(
(group) => async (): Promise<{ group: typeof group; values: ProviderResolutionOutput }> => {
if (group.refs.length > limits.maxRefsPerProvider) {
throw providerResolutionError({
source: group.source,
provider: group.providerName,
message: `Secret provider "${group.providerName}" exceeded maxRefsPerProvider (${limits.maxRefsPerProvider}).`,
});
}
const providerConfig = resolveConfiguredProvider(group.refs[0], options.config);
const values = await resolveProviderRefs({
refs: group.refs,
source: group.source,
providerName: group.providerName,
providerConfig,
options,
limits,
});
return { group, values };
},
);
const taskResults = await runTasksWithConcurrency({
tasks,
limit: limits.maxProviderConcurrency,
errorMode: "stop",
});
if (taskResults.hasError) {
throw taskResults.firstError;
}
const resolved = new Map<string, unknown>();
for (const result of taskResults.results) {
for (const ref of result.group.refs) {
if (!result.values.has(ref.id)) {
throw refResolutionError({
source: result.group.source,
provider: result.group.providerName,
refId: ref.id,
message: `Secret provider "${result.group.providerName}" did not return id "${ref.id}".`,
});
}
resolved.set(secretRefKey(ref), result.values.get(ref.id));
}
}
return resolved;
}
export async function resolveSecretRefValue(
ref: SecretRef,
options: ResolveSecretRefOptions,
): Promise<unknown> {
const cache = options.cache;
const key = secretRefKey(ref);
if (cache?.resolvedByRefKey?.has(key)) {
return await (cache.resolvedByRefKey.get(key) as Promise<unknown>);
}
const promise = (async () => {
const resolved = await resolveSecretRefValues([ref], options);
if (!resolved.has(key)) {
throw refResolutionError({
source: ref.source,
provider: ref.provider,
refId: ref.id,
message: `Secret reference "${key}" resolved to no value.`,
});
}
return resolved.get(key);
})();
if (cache) {
cache.resolvedByRefKey ??= new Map();
cache.resolvedByRefKey.set(key, promise);
}
return await promise;
}
export async function resolveSecretRefString(
ref: SecretRef,
options: ResolveSecretRefOptions,
): Promise<string> {
const resolved = await resolveSecretRefValue(ref, options);
if (!isNonEmptyString(resolved)) {
throw new Error(
`Secret reference "${ref.source}:${ref.provider}:${ref.id}" resolved to a non-string or empty value.`,
);
}
return resolved;
}