Files
openclaw/extensions/vault/src/cli.ts
Sally O'Malley e595a8c0ac Add Vault SecretRef plugin (#89255)
* Add Vault SecretRef plugin

Signed-off-by: sallyom <somalley@redhat.com>

* expand Vault setup to registered SecretRef targets

Signed-off-by: sallyom <somalley@redhat.com>

* fix(vault): use sdk secret target seam

* fix(vault): preserve auth profile target paths

* docs(vault): document plugin enable step

* fix(vault): make status provider-alias aware

* fix(vault): reject noncanonical secret ids

* fix(vault): separate resolver timeout deadlines

* fix(vault): forward private CA trust settings

* fix(secrets): preserve plugin policy boundaries

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-07-09 05:30:12 -05:00

582 lines
18 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { resolveSecretPlanTargetByPath } from "openclaw/plugin-sdk/secret-ref-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { parseVaultSecretId } from "../vault-secret-id.js";
type CommandLike = {
command(name: string): CommandLike;
description(value: string): CommandLike;
option(
flags: string,
description: string,
defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]),
defaultValue?: string[],
): CommandLike;
action<TOptions>(fn: (options: TOptions) => void | Promise<void>): CommandLike;
};
type SecretRef = {
source: "exec";
provider: string;
id: string;
};
type SecretsPlanTarget = {
type: string;
path: string;
pathSegments: string[];
agentId?: string;
providerId?: string;
accountId?: string;
ref: SecretRef;
};
type VaultExecProviderConfig = {
source: "exec";
pluginIntegration: {
pluginId: "vault";
integrationId: "vault";
};
};
type ProviderSecretMapping = {
providerId: string;
secretId: string;
};
type ConfigTargetSecretMapping = {
path: string;
agentId?: string;
secretId: string;
};
type SecretsApplyPlan = {
version: 1;
protocolVersion: 1;
generatedAt: string;
generatedBy: "manual";
providerUpserts: Record<string, VaultExecProviderConfig>;
targets: SecretsPlanTarget[];
};
type RegisterVaultCommandsParams = {
program: CommandLike;
config: OpenClawConfig;
};
type StatusOptions = {
json?: boolean;
providerAlias?: string;
};
type SetupOptions = {
planOut?: string;
providerAlias?: string;
openaiId?: string;
anthropicId?: string;
openrouterId?: string;
providerKey?: string[];
target?: string[];
};
type ProviderStatus = {
configured: boolean;
source?: string;
command?: string;
pluginIntegration?: {
pluginId: string;
integrationId: string;
};
};
const VAULT_PROVIDER_ALIAS = "vault";
const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const MODEL_PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function writeLine(message = ""): void {
process.stdout.write(`${message}\n`);
}
function writeJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function parseDotPath(pathname: string): string[] {
return pathname
.split(".")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
function toDotPath(segments: string[]): string {
return segments.join(".");
}
function assertValidProviderAlias(value: string): void {
if (!SECRET_PROVIDER_ALIAS_PATTERN.test(value)) {
throw new Error(
`Invalid provider alias "${value}". Use lowercase letters, numbers, underscores, or hyphens.`,
);
}
}
function assertValidModelProviderId(label: string, value: string): void {
if (!MODEL_PROVIDER_ID_PATTERN.test(value)) {
throw new Error(`Invalid ${label} model provider id: ${value}`);
}
}
function assertValidVaultSecretId(label: string, value: string): void {
try {
parseVaultSecretId(value);
} catch {
throw new Error(`Invalid ${label} Vault secret id: ${value}`);
}
}
function readProviderStatus(config: OpenClawConfig, providerAlias: string): ProviderStatus {
const provider = config.secrets?.providers?.[providerAlias];
if (!isRecord(provider)) {
return { configured: false };
}
const base = {
configured: true,
source: normalizeOptionalString(provider.source),
};
if (provider.source !== "exec") {
return base;
}
if ("pluginIntegration" in provider) {
return {
...base,
pluginIntegration: provider.pluginIntegration,
};
}
return {
...base,
command: normalizeOptionalString(provider.command),
};
}
function isVaultIntegrationProvider(value: unknown): boolean {
if (!isRecord(value) || value.source !== "exec" || !isRecord(value.pluginIntegration)) {
return false;
}
return (
value.pluginIntegration.pluginId === "vault" &&
value.pluginIntegration.integrationId === "vault"
);
}
function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: string): string {
const explicitAlias = normalizeOptionalString(requestedAlias);
if (explicitAlias) {
assertValidProviderAlias(explicitAlias);
return explicitAlias;
}
if (readProviderStatus(config, VAULT_PROVIDER_ALIAS).configured) {
return VAULT_PROVIDER_ALIAS;
}
const configuredAliases = Object.entries(config.secrets?.providers ?? {})
.filter(([, provider]) => isVaultIntegrationProvider(provider))
.map(([alias]) => alias)
.toSorted();
if (configuredAliases.length > 1) {
throw new Error(
`Multiple Vault provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias <alias>.`,
);
}
return configuredAliases[0] ?? VAULT_PROVIDER_ALIAS;
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
function resolverScriptPathCandidates(baseUrl: string): string[] {
return [
fileURLToPath(new URL("../vault-secret-ref-resolver.js", baseUrl)),
fileURLToPath(new URL("./extensions/vault/vault-secret-ref-resolver.js", baseUrl)),
];
}
async function resolveResolverScriptPath(
baseUrl = import.meta.url,
exists: (filePath: string) => Promise<boolean> = pathExists,
): Promise<string> {
const candidates = resolverScriptPathCandidates(baseUrl);
for (const candidate of candidates) {
if (await exists(candidate)) {
return candidate;
}
}
return candidates[0];
}
function buildProviderConfig(): VaultExecProviderConfig {
return {
source: "exec",
pluginIntegration: {
pluginId: "vault",
integrationId: "vault",
},
};
}
function createModelApiKeyTarget(params: {
providerAlias: string;
providerId: string;
secretId: string;
}): SecretsPlanTarget {
assertValidModelProviderId("target", params.providerId);
return {
type: "models.providers.apiKey",
path: `models.providers.${params.providerId}.apiKey`,
pathSegments: ["models", "providers", params.providerId, "apiKey"],
providerId: params.providerId,
ref: {
source: "exec",
provider: params.providerAlias,
id: params.secretId,
},
};
}
function parseTargetSpecifier(value: string): {
path: string;
agentId?: string;
} {
if (value.startsWith("auth-profiles:")) {
const remainder = value.slice("auth-profiles:".length);
const separatorIndex = remainder.indexOf(":");
const agentId = separatorIndex >= 0 ? remainder.slice(0, separatorIndex) : "";
const targetPath = separatorIndex >= 0 ? remainder.slice(separatorIndex + 1) : "";
if (!agentId || !targetPath) {
throw new Error(`Invalid --target auth-profiles target: ${value}`);
}
return { agentId, path: targetPath };
}
return {
path: value.startsWith("openclaw:") ? value.slice("openclaw:".length) : value,
};
}
function createConfigSecretTarget(params: {
providerAlias: string;
path: string;
agentId?: string;
secretId: string;
}): SecretsPlanTarget {
const pathSegments = parseDotPath(params.path);
const normalizedPath = toDotPath(pathSegments);
if (
pathSegments.length === 0 ||
normalizedPath !== params.path ||
pathSegments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment))
) {
throw new Error(`Invalid --target config path: ${params.path}`);
}
const resolved = resolveSecretPlanTargetByPath({
configFile: params.agentId ? "auth-profiles.json" : "openclaw.json",
pathSegments,
});
if (!resolved) {
throw new Error(`Unknown or unsupported Vault setup target path: ${params.path}`);
}
return {
type: resolved.targetType,
path: normalizedPath,
pathSegments,
...(params.agentId ? { agentId: params.agentId } : {}),
...(resolved.providerId ? { providerId: resolved.providerId } : {}),
...(resolved.accountId ? { accountId: resolved.accountId } : {}),
ref: {
source: "exec",
provider: params.providerAlias,
id: params.secretId,
},
};
}
function parseProviderKeyMappings(values: string[] | undefined): ProviderSecretMapping[] {
return (values ?? []).map((value) => {
const separator = value.indexOf("=");
if (separator <= 0 || separator === value.length - 1) {
throw new Error(
`Invalid --provider-key value "${value}". Use <model-provider-id>=<vault-secret-id>.`,
);
}
const providerId = value.slice(0, separator).trim();
const secretId = value.slice(separator + 1).trim();
assertValidModelProviderId("--provider-key", providerId);
assertValidVaultSecretId(`--provider-key ${providerId}`, secretId);
return { providerId, secretId };
});
}
function parseConfigTargetMappings(values: string[] | undefined): ConfigTargetSecretMapping[] {
return (values ?? []).map((value) => {
const separator = value.indexOf("=");
if (separator <= 0 || separator === value.length - 1) {
throw new Error(
`Invalid --target value "${value}". Use <openclaw-config-path>=<vault-secret-id>.`,
);
}
const target = parseTargetSpecifier(value.slice(0, separator).trim());
const secretId = value.slice(separator + 1).trim();
assertValidVaultSecretId(`--target ${target.path}`, secretId);
return Object.assign(
{ path: target.path, secretId },
target.agentId ? { agentId: target.agentId } : {},
);
});
}
function collectProviderSecrets(options: {
openaiId?: string;
anthropicId?: string;
openrouterId?: string;
providerKey?: string[];
}): ProviderSecretMapping[] {
const providerSecrets: ProviderSecretMapping[] = [];
if (options.openaiId) {
providerSecrets.push({ providerId: "openai", secretId: options.openaiId });
}
if (options.anthropicId) {
providerSecrets.push({ providerId: "anthropic", secretId: options.anthropicId });
}
if (options.openrouterId) {
providerSecrets.push({ providerId: "openrouter", secretId: options.openrouterId });
}
providerSecrets.push(...parseProviderKeyMappings(options.providerKey));
const seen = new Set<string>();
for (const entry of providerSecrets) {
const normalized = entry.providerId.toLowerCase();
if (seen.has(normalized)) {
throw new Error(`Duplicate model provider id in Vault setup: ${entry.providerId}`);
}
seen.add(normalized);
}
return providerSecrets;
}
function assertNoDuplicatePlanTargets(targets: SecretsPlanTarget[]): void {
const seen = new Set<string>();
for (const target of targets) {
const key = target.agentId
? `auth-profiles:${target.agentId}:${target.path}`
: `openclaw:${target.path}`;
if (seen.has(key)) {
throw new Error(`Duplicate secret target path in Vault setup: ${target.path}`);
}
seen.add(key);
}
}
function buildPlan(params: {
providerAlias: string;
providerConfig: VaultExecProviderConfig;
providerSecrets: ProviderSecretMapping[];
configTargetSecrets?: ConfigTargetSecretMapping[];
}): SecretsApplyPlan {
const targets = [
...params.providerSecrets.map((entry) =>
createModelApiKeyTarget({
providerAlias: params.providerAlias,
providerId: entry.providerId,
secretId: entry.secretId,
}),
),
...(params.configTargetSecrets ?? []).map((entry) =>
createConfigSecretTarget({
providerAlias: params.providerAlias,
path: entry.path,
...(entry.agentId ? { agentId: entry.agentId } : {}),
secretId: entry.secretId,
}),
),
];
assertNoDuplicatePlanTargets(targets);
return {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
providerUpserts: {
[params.providerAlias]: params.providerConfig,
},
targets,
};
}
async function promptOptionalSecretId(label: string): Promise<string | undefined> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return undefined;
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
return normalizeOptionalString(await rl.question(`${label} Vault secret id (blank to skip): `));
} finally {
rl.close();
}
}
async function promptProviderSecrets(options: SetupOptions): Promise<ProviderSecretMapping[]> {
const openaiId =
normalizeOptionalString(options.openaiId) ?? (await promptOptionalSecretId("OpenAI"));
const anthropicId =
normalizeOptionalString(options.anthropicId) ?? (await promptOptionalSecretId("Anthropic"));
const openrouterId =
normalizeOptionalString(options.openrouterId) ?? (await promptOptionalSecretId("OpenRouter"));
if (openaiId) {
assertValidVaultSecretId("OpenAI", openaiId);
}
if (anthropicId) {
assertValidVaultSecretId("Anthropic", anthropicId);
}
if (openrouterId) {
assertValidVaultSecretId("OpenRouter", openrouterId);
}
return collectProviderSecrets({
...(openaiId ? { openaiId } : {}),
...(anthropicId ? { anthropicId } : {}),
...(openrouterId ? { openrouterId } : {}),
providerKey: options.providerKey,
});
}
async function runStatus(config: OpenClawConfig, options: StatusOptions): Promise<void> {
const providerAlias = resolveStatusProviderAlias(config, options.providerAlias);
const provider = readProviderStatus(config, providerAlias);
const authMethod = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token";
const result = {
providerAlias,
provider,
resolverScript: await resolveResolverScriptPath(),
vaultAddr: normalizeOptionalString(process.env.VAULT_ADDR),
authMethod,
authMount:
normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_MOUNT) ??
(authMethod === "kubernetes" ? "kubernetes" : "jwt"),
authRole: normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE),
hasJwtFile: Boolean(normalizeOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE)),
hasVaultTokenFile: Boolean(normalizeOptionalString(process.env.VAULT_TOKEN_FILE)),
kvMount: normalizeOptionalString(process.env.OPENCLAW_VAULT_KV_MOUNT) ?? "secret",
kvVersion: normalizeOptionalString(process.env.OPENCLAW_VAULT_KV_VERSION) ?? "2",
hasVaultToken: Boolean(normalizeOptionalString(process.env.VAULT_TOKEN)),
};
if (options.json) {
writeJson(result);
return;
}
writeLine(`Vault provider: ${provider.configured ? "configured" : "not configured"}`);
if (provider.source) {
writeLine(`Source: ${provider.source}`);
}
if (provider.command) {
writeLine(`Command: ${provider.command}`);
}
if (provider.pluginIntegration) {
writeLine(
`Plugin integration: ${provider.pluginIntegration.pluginId}:${provider.pluginIntegration.integrationId}`,
);
}
writeLine(`Resolver: ${result.resolverScript}`);
writeLine(`VAULT_ADDR: ${result.vaultAddr ?? "not set"}`);
writeLine(`Auth method: ${result.authMethod}`);
writeLine(`VAULT_TOKEN: ${result.hasVaultToken ? "set" : "not set"}`);
writeLine(`VAULT_TOKEN_FILE: ${result.hasVaultTokenFile ? "set" : "not set"}`);
writeLine(`Auth mount: ${result.authMount}`);
writeLine(`Auth role: ${result.authRole ?? "not set"}`);
writeLine(`OPENCLAW_VAULT_JWT_FILE: ${result.hasJwtFile ? "set" : "not set"}`);
writeLine(`KV mount: ${result.kvMount}`);
writeLine(`KV version: ${result.kvVersion}`);
}
async function runSetup(options: SetupOptions): Promise<void> {
const providerAlias = normalizeOptionalString(options.providerAlias) ?? VAULT_PROVIDER_ALIAS;
assertValidProviderAlias(providerAlias);
const providerSecrets = await promptProviderSecrets(options);
const plan = buildPlan({
providerAlias,
providerConfig: buildProviderConfig(),
providerSecrets,
configTargetSecrets: parseConfigTargetMappings(options.target),
});
const planPath =
normalizeOptionalString(options.planOut) ??
path.join(resolvePreferredOpenClawTmpDir(), `openclaw-vault-secrets-${process.pid}.json`);
await fs.writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
writeLine(`Plan written to ${planPath}`);
writeLine(`Targets: ${plan.targets.length}`);
writeLine("");
writeLine("Next steps:");
writeLine(` openclaw secrets apply --from ${planPath} --dry-run --allow-exec`);
writeLine(` openclaw secrets apply --from ${planPath} --allow-exec`);
writeLine(" openclaw secrets audit --check --allow-exec");
writeLine(" openclaw secrets reload");
}
export function registerVaultCommands(params: RegisterVaultCommandsParams): void {
const vault = params.program.command("vault").description("Manage Vault SecretRefs");
vault
.command("status")
.description("Show Vault SecretRef provider status")
.option("--json", "Print JSON status")
.option("--provider-alias <alias>", "Secret provider alias to inspect")
.action((options: StatusOptions) => runStatus(params.config, options));
vault
.command("setup")
.description("Create a Vault SecretRef setup plan")
.option("--plan-out <path>", "Write the generated secrets apply plan to a path")
.option("--provider-alias <alias>", "Secret provider alias to configure", VAULT_PROVIDER_ALIAS)
.option("--openai-id <id>", "Vault secret id for models.providers.openai.apiKey")
.option("--anthropic-id <id>", "Vault secret id for models.providers.anthropic.apiKey")
.option("--openrouter-id <id>", "Vault secret id for models.providers.openrouter.apiKey")
.option(
"--provider-key <provider=id>",
"Vault secret id for any models.providers.<provider>.apiKey target",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.option(
"--target <path=id>",
"Vault secret id for any known SecretRef target path",
(value: string, previous: string[] = []) => [...previous, value],
[],
)
.action((options: SetupOptions) => runSetup(options));
}
export const testing = {
buildPlan,
buildProviderConfig,
collectProviderSecrets,
createConfigSecretTarget,
createModelApiKeyTarget,
parseConfigTargetMappings,
parseProviderKeyMappings,
resolveStatusProviderAlias,
resolveResolverScriptPath,
resolverScriptPathCandidates,
};