mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 05:12:15 +00:00
* feat(secrets): expand secret target coverage and gateway tooling * docs(secrets): align gateway and CLI secret docs * chore(protocol): regenerate swift gateway models for secrets methods * fix(config): restore talk apiKey fallback and stabilize runner test * ci(windows): reduce test worker count for shard stability * ci(windows): raise node heap for test shard stability * test(feishu): make proxy env precedence assertion windows-safe * fix(gateway): resolve auth password SecretInput refs for clients * fix(gateway): resolve remote SecretInput credentials for clients * fix(secrets): skip inactive refs in command snapshot assignments * fix(secrets): scope gateway.remote refs to effective auth surfaces * fix(secrets): ignore memory defaults when enabled agents disable search * fix(secrets): honor Google Chat serviceAccountRef inheritance * fix(secrets): address tsgo errors in command and gateway collectors * fix(secrets): avoid auth-store load in providers-only configure * fix(gateway): defer local password ref resolution by precedence * fix(secrets): gate telegram webhook secret refs by webhook mode * fix(secrets): gate slack signing secret refs to http mode * fix(secrets): skip telegram botToken refs when tokenFile is set * fix(secrets): gate discord pluralkit refs by enabled flag * fix(secrets): gate discord voice tts refs by voice enabled * test(secrets): make runtime fixture modes explicit * fix(cli): resolve local qr password secret refs * fix(cli): fail when gateway leaves command refs unresolved * fix(gateway): fail when local password SecretRef is unresolved * fix(gateway): fail when required remote SecretRefs are unresolved * fix(gateway): resolve local password refs only when password can win * fix(cli): skip local password SecretRef resolution on qr token override * test(gateway): cast SecretRef fixtures to OpenClawConfig * test(secrets): activate mode-gated targets in runtime coverage fixture * fix(cron): support SecretInput webhook tokens safely * fix(bluebubbles): support SecretInput passwords across config paths * fix(msteams): make appPassword SecretInput-safe in onboarding/token paths * fix(bluebubbles): align SecretInput schema helper typing * fix(cli): clarify secrets.resolve version-skew errors * refactor(secrets): return structured inactive paths from secrets.resolve * refactor(gateway): type onboarding secret writes as SecretInput * chore(protocol): regenerate swift models for secrets.resolve * feat(secrets): expand extension credential secretref support * fix(secrets): gate web-search refs by active provider * fix(onboarding): detect SecretRef credentials in extension status * fix(onboarding): allow keeping existing ref in secret prompt * fix(onboarding): resolve gateway password SecretRefs for probe and tui * fix(onboarding): honor secret-input-mode for local gateway auth * fix(acp): resolve gateway SecretInput credentials * fix(secrets): gate gateway.remote refs to remote surfaces * test(secrets): cover pattern matching and inactive array refs * docs(secrets): clarify secrets.resolve and remote active surfaces * fix(bluebubbles): keep existing SecretRef during onboarding * fix(tests): resolve CI type errors in new SecretRef coverage * fix(extensions): replace raw fetch with SSRF-guarded fetch * test(secrets): mark gateway remote targets active in runtime coverage * test(infra): normalize home-prefix expectation across platforms * fix(cli): only resolve local qr password refs in password mode * test(cli): cover local qr token mode with unresolved password ref * docs(cli): clarify local qr password ref resolution behavior * refactor(extensions): reuse sdk SecretInput helpers * fix(wizard): resolve onboarding env-template secrets before plaintext * fix(cli): surface secrets.resolve diagnostics in memory and qr * test(secrets): repair post-rebase runtime and fixtures * fix(gateway): skip remote password ref resolution when token wins * fix(secrets): treat tailscale remote gateway refs as active * fix(gateway): allow remote password fallback when token ref is unresolved * fix(gateway): ignore stale local password refs for none and trusted-proxy * fix(gateway): skip remote secret ref resolution on local call paths * test(cli): cover qr remote tailscale secret ref resolution * fix(secrets): align gateway password active-surface with auth inference * fix(cli): resolve inferred local gateway password refs in qr * fix(gateway): prefer resolvable remote password over token ref pre-resolution * test(gateway): cover none and trusted-proxy stale password refs * docs(secrets): sync qr and gateway active-surface behavior * fix: restore stability blockers from pre-release audit * Secrets: fix collector/runtime precedence contradictions * docs: align secrets and web credential docs * fix(rebase): resolve integration regressions after main rebase * fix(node-host): resolve gateway secret refs for auth * fix(secrets): harden secretinput runtime readers * gateway: skip inactive auth secretref resolution * cli: avoid gateway preflight for inactive secret refs * extensions: allow unresolved refs in onboarding status * tests: fix qr-cli module mock hoist ordering * Security: align audit checks with SecretInput resolution * Gateway: resolve local-mode remote fallback secret refs * Node host: avoid resolving inactive password secret refs * Secrets runtime: mark Slack appToken inactive for HTTP mode * secrets: keep inactive gateway remote refs non-blocking * cli: include agent memory secret targets in runtime resolution * docs(secrets): sync docs with active-surface and web search behavior * fix(secrets): keep telegram top-level token refs active for blank account tokens * fix(daemon): resolve gateway password secret refs for probe auth * fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled * fix(secrets): align token inheritance and exec timeout defaults * docs(secrets): clarify active-surface notes in cli docs * cli: require secrets.resolve gateway capability * gateway: log auth secret surface diagnostics * secrets: remove dead provider resolver module * fix(secrets): restore gateway auth precedence and fallback resolution * fix(tests): align plugin runtime mock typings --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
958 lines
28 KiB
TypeScript
958 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 {
|
|
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 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 safeStat(effectivePath);
|
|
if (!stat.ok) {
|
|
throw new Error(`${params.label} is not readable: ${effectivePath}`);
|
|
}
|
|
if (stat.isDir) {
|
|
throw new Error(`${params.label} must be a file: ${effectivePath}`);
|
|
}
|
|
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 safeStat(effectivePath);
|
|
if (!stat.ok) {
|
|
throw new Error(`${params.label} is not readable: ${effectivePath}`);
|
|
}
|
|
if (stat.isDir) {
|
|
throw new Error(`${params.label} must be a file: ${effectivePath}`);
|
|
}
|
|
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) {
|
|
if (isSecretResolutionError(err)) {
|
|
throw err;
|
|
}
|
|
throw providerResolutionError({
|
|
source: "file",
|
|
provider: params.providerName,
|
|
message: describeUnknownError(err),
|
|
cause: 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) {
|
|
if (isSecretResolutionError(err)) {
|
|
throw err;
|
|
}
|
|
throw providerResolutionError({
|
|
source: "exec",
|
|
provider: params.providerName,
|
|
message: describeUnknownError(err),
|
|
cause: 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) {
|
|
if (isSecretResolutionError(err)) {
|
|
throw err;
|
|
}
|
|
throw providerResolutionError({
|
|
source: "exec",
|
|
provider: params.providerName,
|
|
message: describeUnknownError(err),
|
|
cause: 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) {
|
|
if (isSecretResolutionError(err)) {
|
|
throw err;
|
|
}
|
|
throw providerResolutionError({
|
|
source: "exec",
|
|
provider: params.providerName,
|
|
message: describeUnknownError(err),
|
|
cause: 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) {
|
|
if (isSecretResolutionError(err)) {
|
|
throw err;
|
|
}
|
|
throw providerResolutionError({
|
|
source: params.source,
|
|
provider: params.providerName,
|
|
message: describeUnknownError(err),
|
|
cause: 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.");
|
|
}
|
|
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;
|
|
}
|