* Secrets: harden exec SecretRef validation and reload LKG coverage * Tests: harden exec fast-exit stdin regression case * Tests: align lifecycle daemon test formatting with oxfmt 0.36
992 lines
30 KiB
TypeScript
992 lines
30 KiB
TypeScript
import path from "node:path";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { confirm, select, text } from "@clack/prompts";
|
|
import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import type { AuthProfileStore } from "../agents/auth-profiles.js";
|
|
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
|
|
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { SecretProviderConfig, SecretRef, SecretRefSource } from "../config/types.secrets.js";
|
|
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
|
import { normalizeAgentId } from "../routing/session-key.js";
|
|
import { runSecretsApply, type SecretsApplyResult } from "./apply.js";
|
|
import { createSecretsConfigIO } from "./config-io.js";
|
|
import {
|
|
buildConfigureCandidatesForScope,
|
|
buildSecretsConfigurePlan,
|
|
collectConfigureProviderChanges,
|
|
hasConfigurePlanChanges,
|
|
type ConfigureCandidate,
|
|
} from "./configure-plan.js";
|
|
import type { SecretsApplyPlan } from "./plan.js";
|
|
import { PROVIDER_ENV_VARS } from "./provider-env-vars.js";
|
|
import {
|
|
formatExecSecretRefIdValidationMessage,
|
|
isValidExecSecretRefId,
|
|
isValidSecretProviderAlias,
|
|
resolveDefaultSecretProviderAlias,
|
|
} from "./ref-contract.js";
|
|
import { resolveSecretRefValue } from "./resolve.js";
|
|
import { assertExpectedResolvedSecretValue } from "./secret-value.js";
|
|
import { isRecord } from "./shared.js";
|
|
import { readJsonObjectIfExists } from "./storage-scan.js";
|
|
|
|
export type SecretsConfigureResult = {
|
|
plan: SecretsApplyPlan;
|
|
preflight: SecretsApplyResult;
|
|
};
|
|
|
|
const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
|
|
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
|
|
|
function isAbsolutePathValue(value: string): boolean {
|
|
return (
|
|
path.isAbsolute(value) ||
|
|
WINDOWS_ABS_PATH_PATTERN.test(value) ||
|
|
WINDOWS_UNC_PATH_PATTERN.test(value)
|
|
);
|
|
}
|
|
|
|
function parseCsv(value: string): string[] {
|
|
return value
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
}
|
|
|
|
function parseOptionalPositiveInt(value: string, max: number): number | undefined {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
if (!/^\d+$/.test(trimmed)) {
|
|
return undefined;
|
|
}
|
|
const parsed = Number.parseInt(trimmed, 10);
|
|
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > max) {
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function getSecretProviders(config: OpenClawConfig): Record<string, SecretProviderConfig> {
|
|
if (!isRecord(config.secrets?.providers)) {
|
|
return {};
|
|
}
|
|
return config.secrets.providers;
|
|
}
|
|
|
|
function setSecretProvider(
|
|
config: OpenClawConfig,
|
|
providerAlias: string,
|
|
providerConfig: SecretProviderConfig,
|
|
): void {
|
|
config.secrets ??= {};
|
|
if (!isRecord(config.secrets.providers)) {
|
|
config.secrets.providers = {};
|
|
}
|
|
config.secrets.providers[providerAlias] = providerConfig;
|
|
}
|
|
|
|
function removeSecretProvider(config: OpenClawConfig, providerAlias: string): boolean {
|
|
if (!isRecord(config.secrets?.providers)) {
|
|
return false;
|
|
}
|
|
const providers = config.secrets.providers;
|
|
if (!Object.prototype.hasOwnProperty.call(providers, providerAlias)) {
|
|
return false;
|
|
}
|
|
delete providers[providerAlias];
|
|
if (Object.keys(providers).length === 0) {
|
|
delete config.secrets?.providers;
|
|
}
|
|
|
|
if (isRecord(config.secrets?.defaults)) {
|
|
const defaults = config.secrets.defaults;
|
|
if (defaults?.env === providerAlias) {
|
|
delete defaults.env;
|
|
}
|
|
if (defaults?.file === providerAlias) {
|
|
delete defaults.file;
|
|
}
|
|
if (defaults?.exec === providerAlias) {
|
|
delete defaults.exec;
|
|
}
|
|
if (
|
|
defaults &&
|
|
defaults.env === undefined &&
|
|
defaults.file === undefined &&
|
|
defaults.exec === undefined
|
|
) {
|
|
delete config.secrets?.defaults;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function providerHint(provider: SecretProviderConfig): string {
|
|
if (provider.source === "env") {
|
|
return provider.allowlist?.length ? `env (${provider.allowlist.length} allowlisted)` : "env";
|
|
}
|
|
if (provider.source === "file") {
|
|
return `file (${provider.mode ?? "json"})`;
|
|
}
|
|
return `exec (${provider.jsonOnly === false ? "json+text" : "json"})`;
|
|
}
|
|
|
|
function toSourceChoices(config: OpenClawConfig): Array<{ value: SecretRefSource; label: string }> {
|
|
const hasSource = (source: SecretRefSource) =>
|
|
Object.values(config.secrets?.providers ?? {}).some((provider) => provider?.source === source);
|
|
const choices: Array<{ value: SecretRefSource; label: string }> = [
|
|
{
|
|
value: "env",
|
|
label: "env",
|
|
},
|
|
];
|
|
if (hasSource("file")) {
|
|
choices.push({ value: "file", label: "file" });
|
|
}
|
|
if (hasSource("exec")) {
|
|
choices.push({ value: "exec", label: "exec" });
|
|
}
|
|
return choices;
|
|
}
|
|
|
|
function assertNoCancel<T>(value: T | symbol, message: string): T {
|
|
if (typeof value === "symbol") {
|
|
throw new Error(message);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const AUTH_PROFILE_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
|
|
|
|
function validateEnvNameCsv(value: string): string | undefined {
|
|
const entries = parseCsv(value);
|
|
for (const entry of entries) {
|
|
if (!ENV_NAME_PATTERN.test(entry)) {
|
|
return `Invalid env name: ${entry}`;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function promptEnvNameCsv(params: {
|
|
message: string;
|
|
initialValue: string;
|
|
}): Promise<string[]> {
|
|
const raw = assertNoCancel(
|
|
await text({
|
|
message: params.message,
|
|
initialValue: params.initialValue,
|
|
validate: (value) => validateEnvNameCsv(String(value ?? "")),
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
return parseCsv(String(raw ?? ""));
|
|
}
|
|
|
|
async function promptOptionalPositiveInt(params: {
|
|
message: string;
|
|
initialValue?: number;
|
|
max: number;
|
|
}): Promise<number | undefined> {
|
|
const raw = assertNoCancel(
|
|
await text({
|
|
message: params.message,
|
|
initialValue: params.initialValue === undefined ? "" : String(params.initialValue),
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const parsed = parseOptionalPositiveInt(trimmed, params.max);
|
|
if (parsed === undefined) {
|
|
return `Must be an integer between 1 and ${params.max}`;
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
const parsed = parseOptionalPositiveInt(String(raw ?? ""), params.max);
|
|
return parsed;
|
|
}
|
|
|
|
function configureCandidateKey(candidate: {
|
|
configFile: "openclaw.json" | "auth-profiles.json";
|
|
path: string;
|
|
agentId?: string;
|
|
}): string {
|
|
if (candidate.configFile === "auth-profiles.json") {
|
|
return `auth-profiles:${String(candidate.agentId ?? "").trim()}:${candidate.path}`;
|
|
}
|
|
return `openclaw:${candidate.path}`;
|
|
}
|
|
|
|
function hasSourceChoice(
|
|
sourceChoices: Array<{ value: SecretRefSource; label: string }>,
|
|
source: SecretRefSource,
|
|
): boolean {
|
|
return sourceChoices.some((entry) => entry.value === source);
|
|
}
|
|
|
|
function resolveCandidateProviderHint(candidate: ConfigureCandidate): string | undefined {
|
|
if (typeof candidate.authProfileProvider === "string" && candidate.authProfileProvider.trim()) {
|
|
return candidate.authProfileProvider.trim().toLowerCase();
|
|
}
|
|
if (typeof candidate.providerId === "string" && candidate.providerId.trim()) {
|
|
return candidate.providerId.trim().toLowerCase();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveSuggestedEnvSecretId(candidate: ConfigureCandidate): string | undefined {
|
|
const hintedProvider = resolveCandidateProviderHint(candidate);
|
|
if (!hintedProvider) {
|
|
return undefined;
|
|
}
|
|
const envCandidates = PROVIDER_ENV_VARS[hintedProvider];
|
|
if (!Array.isArray(envCandidates) || envCandidates.length === 0) {
|
|
return undefined;
|
|
}
|
|
return envCandidates[0];
|
|
}
|
|
|
|
function resolveConfigureAgentId(config: OpenClawConfig, explicitAgentId?: string): string {
|
|
const knownAgentIds = new Set(listAgentIds(config));
|
|
if (!explicitAgentId) {
|
|
return resolveDefaultAgentId(config);
|
|
}
|
|
const normalized = normalizeAgentId(explicitAgentId);
|
|
if (knownAgentIds.has(normalized)) {
|
|
return normalized;
|
|
}
|
|
const known = [...knownAgentIds].toSorted().join(", ");
|
|
throw new Error(
|
|
`Unknown agent id "${explicitAgentId}". Known agents: ${known || "none configured"}.`,
|
|
);
|
|
}
|
|
|
|
function normalizeAuthStoreForConfigure(
|
|
raw: Record<string, unknown> | null,
|
|
storePath: string,
|
|
): AuthProfileStore {
|
|
if (!raw) {
|
|
return {
|
|
version: AUTH_STORE_VERSION,
|
|
profiles: {},
|
|
};
|
|
}
|
|
if (!isRecord(raw.profiles)) {
|
|
throw new Error(
|
|
`Cannot run interactive secrets configure because ${storePath} is invalid (missing "profiles" object).`,
|
|
);
|
|
}
|
|
const version = typeof raw.version === "number" && Number.isFinite(raw.version) ? raw.version : 1;
|
|
return {
|
|
version,
|
|
profiles: raw.profiles as AuthProfileStore["profiles"],
|
|
...(isRecord(raw.order) ? { order: raw.order as AuthProfileStore["order"] } : {}),
|
|
...(isRecord(raw.lastGood) ? { lastGood: raw.lastGood as AuthProfileStore["lastGood"] } : {}),
|
|
...(isRecord(raw.usageStats)
|
|
? { usageStats: raw.usageStats as AuthProfileStore["usageStats"] }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function loadAuthProfileStoreForConfigure(params: {
|
|
config: OpenClawConfig;
|
|
agentId: string;
|
|
}): AuthProfileStore {
|
|
const agentDir = resolveAgentDir(params.config, params.agentId);
|
|
const storePath = resolveAuthStorePath(agentDir);
|
|
const parsed = readJsonObjectIfExists(storePath);
|
|
if (parsed.error) {
|
|
throw new Error(
|
|
`Cannot run interactive secrets configure because ${storePath} could not be read: ${parsed.error}`,
|
|
);
|
|
}
|
|
return normalizeAuthStoreForConfigure(parsed.value, storePath);
|
|
}
|
|
|
|
async function promptNewAuthProfileCandidate(agentId: string): Promise<ConfigureCandidate> {
|
|
const profileId = assertNoCancel(
|
|
await text({
|
|
message: "Auth profile id",
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return "Required";
|
|
}
|
|
if (!AUTH_PROFILE_ID_PATTERN.test(trimmed)) {
|
|
return 'Use letters/numbers/":"/"_"/"-" only.';
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const credentialType = assertNoCancel(
|
|
await select({
|
|
message: "Auth profile credential type",
|
|
options: [
|
|
{ value: "api_key", label: "api_key (key/keyRef)" },
|
|
{ value: "token", label: "token (token/tokenRef)" },
|
|
],
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const provider = assertNoCancel(
|
|
await text({
|
|
message: "Provider id",
|
|
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const profileIdTrimmed = String(profileId).trim();
|
|
const providerTrimmed = String(provider).trim();
|
|
if (credentialType === "token") {
|
|
return {
|
|
type: "auth-profiles.token.token",
|
|
path: `profiles.${profileIdTrimmed}.token`,
|
|
pathSegments: ["profiles", profileIdTrimmed, "token"],
|
|
label: `profiles.${profileIdTrimmed}.token (auth profile, agent ${agentId})`,
|
|
configFile: "auth-profiles.json",
|
|
agentId,
|
|
authProfileProvider: providerTrimmed,
|
|
expectedResolvedValue: "string",
|
|
};
|
|
}
|
|
return {
|
|
type: "auth-profiles.api_key.key",
|
|
path: `profiles.${profileIdTrimmed}.key`,
|
|
pathSegments: ["profiles", profileIdTrimmed, "key"],
|
|
label: `profiles.${profileIdTrimmed}.key (auth profile, agent ${agentId})`,
|
|
configFile: "auth-profiles.json",
|
|
agentId,
|
|
authProfileProvider: providerTrimmed,
|
|
expectedResolvedValue: "string",
|
|
};
|
|
}
|
|
|
|
async function promptProviderAlias(params: { existingAliases: Set<string> }): Promise<string> {
|
|
const alias = assertNoCancel(
|
|
await text({
|
|
message: "Provider alias",
|
|
initialValue: "default",
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return "Required";
|
|
}
|
|
if (!isValidSecretProviderAlias(trimmed)) {
|
|
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
|
|
}
|
|
if (params.existingAliases.has(trimmed)) {
|
|
return "Alias already exists";
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
return String(alias).trim();
|
|
}
|
|
|
|
async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRefSource> {
|
|
const source = assertNoCancel(
|
|
await select({
|
|
message: "Provider source",
|
|
options: [
|
|
{ value: "env", label: "env" },
|
|
{ value: "file", label: "file" },
|
|
{ value: "exec", label: "exec" },
|
|
],
|
|
initialValue: initial,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
return source as SecretRefSource;
|
|
}
|
|
|
|
async function promptEnvProvider(
|
|
base?: Extract<SecretProviderConfig, { source: "env" }>,
|
|
): Promise<Extract<SecretProviderConfig, { source: "env" }>> {
|
|
const allowlist = await promptEnvNameCsv({
|
|
message: "Env allowlist (comma-separated, blank for unrestricted)",
|
|
initialValue: base?.allowlist?.join(",") ?? "",
|
|
});
|
|
return {
|
|
source: "env",
|
|
...(allowlist.length > 0 ? { allowlist } : {}),
|
|
};
|
|
}
|
|
|
|
async function promptFileProvider(
|
|
base?: Extract<SecretProviderConfig, { source: "file" }>,
|
|
): Promise<Extract<SecretProviderConfig, { source: "file" }>> {
|
|
const filePath = assertNoCancel(
|
|
await text({
|
|
message: "File path (absolute)",
|
|
initialValue: base?.path ?? "",
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return "Required";
|
|
}
|
|
if (!isAbsolutePathValue(trimmed)) {
|
|
return "Must be an absolute path";
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const mode = assertNoCancel(
|
|
await select({
|
|
message: "File mode",
|
|
options: [
|
|
{ value: "json", label: "json" },
|
|
{ value: "singleValue", label: "singleValue" },
|
|
],
|
|
initialValue: base?.mode ?? "json",
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const timeoutMs = await promptOptionalPositiveInt({
|
|
message: "Timeout ms (blank for default)",
|
|
initialValue: base?.timeoutMs,
|
|
max: 120000,
|
|
});
|
|
const maxBytes = await promptOptionalPositiveInt({
|
|
message: "Max bytes (blank for default)",
|
|
initialValue: base?.maxBytes,
|
|
max: 20 * 1024 * 1024,
|
|
});
|
|
|
|
return {
|
|
source: "file",
|
|
path: String(filePath).trim(),
|
|
mode,
|
|
...(timeoutMs ? { timeoutMs } : {}),
|
|
...(maxBytes ? { maxBytes } : {}),
|
|
};
|
|
}
|
|
|
|
async function parseArgsInput(rawValue: string): Promise<string[] | undefined> {
|
|
const trimmed = rawValue.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const parsed = JSON.parse(trimmed) as unknown;
|
|
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
|
|
throw new Error("args must be a JSON array of strings");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function promptExecProvider(
|
|
base?: Extract<SecretProviderConfig, { source: "exec" }>,
|
|
): Promise<Extract<SecretProviderConfig, { source: "exec" }>> {
|
|
const command = assertNoCancel(
|
|
await text({
|
|
message: "Command path (absolute)",
|
|
initialValue: base?.command ?? "",
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return "Required";
|
|
}
|
|
if (!isAbsolutePathValue(trimmed)) {
|
|
return "Must be an absolute path";
|
|
}
|
|
if (!isSafeExecutableValue(trimmed)) {
|
|
return "Command value is not allowed";
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const argsRaw = assertNoCancel(
|
|
await text({
|
|
message: "Args JSON array (blank for none)",
|
|
initialValue: JSON.stringify(base?.args ?? []),
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as unknown;
|
|
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
|
|
return "Must be a JSON array of strings";
|
|
}
|
|
return undefined;
|
|
} catch {
|
|
return "Must be valid JSON";
|
|
}
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const timeoutMs = await promptOptionalPositiveInt({
|
|
message: "Timeout ms (blank for default)",
|
|
initialValue: base?.timeoutMs,
|
|
max: 120000,
|
|
});
|
|
|
|
const noOutputTimeoutMs = await promptOptionalPositiveInt({
|
|
message: "No-output timeout ms (blank for default)",
|
|
initialValue: base?.noOutputTimeoutMs,
|
|
max: 120000,
|
|
});
|
|
|
|
const maxOutputBytes = await promptOptionalPositiveInt({
|
|
message: "Max output bytes (blank for default)",
|
|
initialValue: base?.maxOutputBytes,
|
|
max: 20 * 1024 * 1024,
|
|
});
|
|
|
|
const jsonOnly = assertNoCancel(
|
|
await confirm({
|
|
message: "Require JSON-only response?",
|
|
initialValue: base?.jsonOnly ?? true,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const passEnv = await promptEnvNameCsv({
|
|
message: "Pass-through env vars (comma-separated, blank for none)",
|
|
initialValue: base?.passEnv?.join(",") ?? "",
|
|
});
|
|
|
|
const trustedDirsRaw = assertNoCancel(
|
|
await text({
|
|
message: "Trusted dirs (comma-separated absolute paths, blank for none)",
|
|
initialValue: base?.trustedDirs?.join(",") ?? "",
|
|
validate: (value) => {
|
|
const entries = parseCsv(String(value ?? ""));
|
|
for (const entry of entries) {
|
|
if (!isAbsolutePathValue(entry)) {
|
|
return `Trusted dir must be absolute: ${entry}`;
|
|
}
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const allowInsecurePath = assertNoCancel(
|
|
await confirm({
|
|
message: "Allow insecure command path checks?",
|
|
initialValue: base?.allowInsecurePath ?? false,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
const allowSymlinkCommand = assertNoCancel(
|
|
await confirm({
|
|
message: "Allow symlink command path?",
|
|
initialValue: base?.allowSymlinkCommand ?? false,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const args = await parseArgsInput(String(argsRaw ?? ""));
|
|
const trustedDirs = parseCsv(String(trustedDirsRaw ?? ""));
|
|
|
|
return {
|
|
source: "exec",
|
|
command: String(command).trim(),
|
|
...(args && args.length > 0 ? { args } : {}),
|
|
...(timeoutMs ? { timeoutMs } : {}),
|
|
...(noOutputTimeoutMs ? { noOutputTimeoutMs } : {}),
|
|
...(maxOutputBytes ? { maxOutputBytes } : {}),
|
|
...(jsonOnly ? { jsonOnly } : { jsonOnly: false }),
|
|
...(passEnv.length > 0 ? { passEnv } : {}),
|
|
...(trustedDirs.length > 0 ? { trustedDirs } : {}),
|
|
...(allowInsecurePath ? { allowInsecurePath: true } : {}),
|
|
...(allowSymlinkCommand ? { allowSymlinkCommand: true } : {}),
|
|
...(isRecord(base?.env) ? { env: base.env } : {}),
|
|
};
|
|
}
|
|
|
|
async function promptProviderConfig(
|
|
source: SecretRefSource,
|
|
current?: SecretProviderConfig,
|
|
): Promise<SecretProviderConfig> {
|
|
if (source === "env") {
|
|
return await promptEnvProvider(current?.source === "env" ? current : undefined);
|
|
}
|
|
if (source === "file") {
|
|
return await promptFileProvider(current?.source === "file" ? current : undefined);
|
|
}
|
|
return await promptExecProvider(current?.source === "exec" ? current : undefined);
|
|
}
|
|
|
|
async function configureProvidersInteractive(config: OpenClawConfig): Promise<void> {
|
|
while (true) {
|
|
const providers = getSecretProviders(config);
|
|
const providerEntries = Object.entries(providers).toSorted(([left], [right]) =>
|
|
left.localeCompare(right),
|
|
);
|
|
|
|
const actionOptions: Array<{ value: string; label: string; hint?: string }> = [
|
|
{
|
|
value: "add",
|
|
label: "Add provider",
|
|
hint: "Define a new env/file/exec provider",
|
|
},
|
|
];
|
|
if (providerEntries.length > 0) {
|
|
actionOptions.push({
|
|
value: "edit",
|
|
label: "Edit provider",
|
|
hint: "Update an existing provider",
|
|
});
|
|
actionOptions.push({
|
|
value: "remove",
|
|
label: "Remove provider",
|
|
hint: "Delete a provider alias",
|
|
});
|
|
}
|
|
actionOptions.push({
|
|
value: "continue",
|
|
label: "Continue",
|
|
hint: "Move to credential mapping",
|
|
});
|
|
|
|
const action = assertNoCancel(
|
|
await select({
|
|
message:
|
|
providerEntries.length > 0
|
|
? "Configure secret providers"
|
|
: "Configure secret providers (only env refs are available until file/exec providers are added)",
|
|
options: actionOptions,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
if (action === "continue") {
|
|
return;
|
|
}
|
|
|
|
if (action === "add") {
|
|
const source = await promptProviderSource();
|
|
const alias = await promptProviderAlias({
|
|
existingAliases: new Set(providerEntries.map(([providerAlias]) => providerAlias)),
|
|
});
|
|
const providerConfig = await promptProviderConfig(source);
|
|
setSecretProvider(config, alias, providerConfig);
|
|
continue;
|
|
}
|
|
|
|
if (action === "edit") {
|
|
const alias = assertNoCancel(
|
|
await select({
|
|
message: "Select provider to edit",
|
|
options: providerEntries.map(([providerAlias, providerConfig]) => ({
|
|
value: providerAlias,
|
|
label: providerAlias,
|
|
hint: providerHint(providerConfig),
|
|
})),
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
const current = providers[alias];
|
|
if (!current) {
|
|
continue;
|
|
}
|
|
const source = await promptProviderSource(current.source);
|
|
const nextProviderConfig = await promptProviderConfig(source, current);
|
|
if (!isDeepStrictEqual(current, nextProviderConfig)) {
|
|
setSecretProvider(config, alias, nextProviderConfig);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (action === "remove") {
|
|
const alias = assertNoCancel(
|
|
await select({
|
|
message: "Select provider to remove",
|
|
options: providerEntries.map(([providerAlias, providerConfig]) => ({
|
|
value: providerAlias,
|
|
label: providerAlias,
|
|
hint: providerHint(providerConfig),
|
|
})),
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
const shouldRemove = assertNoCancel(
|
|
await confirm({
|
|
message: `Remove provider "${alias}"?`,
|
|
initialValue: false,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
if (shouldRemove) {
|
|
removeSecretProvider(config, alias);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function runSecretsConfigureInteractive(
|
|
params: {
|
|
env?: NodeJS.ProcessEnv;
|
|
providersOnly?: boolean;
|
|
skipProviderSetup?: boolean;
|
|
agentId?: string;
|
|
} = {},
|
|
): Promise<SecretsConfigureResult> {
|
|
if (!process.stdin.isTTY) {
|
|
throw new Error("secrets configure requires an interactive TTY.");
|
|
}
|
|
if (params.providersOnly && params.skipProviderSetup) {
|
|
throw new Error("Cannot combine --providers-only with --skip-provider-setup.");
|
|
}
|
|
|
|
const env = params.env ?? process.env;
|
|
const io = createSecretsConfigIO({ env });
|
|
const { snapshot } = await io.readConfigFileSnapshotForWrite();
|
|
if (!snapshot.valid) {
|
|
throw new Error("Cannot run interactive secrets configure because config is invalid.");
|
|
}
|
|
|
|
const stagedConfig = structuredClone(snapshot.config);
|
|
if (!params.skipProviderSetup) {
|
|
await configureProvidersInteractive(stagedConfig);
|
|
}
|
|
|
|
const providerChanges = collectConfigureProviderChanges({
|
|
original: snapshot.config,
|
|
next: stagedConfig,
|
|
});
|
|
|
|
const selectedByPath = new Map<string, ConfigureCandidate & { ref: SecretRef }>();
|
|
if (!params.providersOnly) {
|
|
const configureAgentId = resolveConfigureAgentId(snapshot.config, params.agentId);
|
|
const authStore = loadAuthProfileStoreForConfigure({
|
|
config: snapshot.config,
|
|
agentId: configureAgentId,
|
|
});
|
|
const candidates = buildConfigureCandidatesForScope({
|
|
config: stagedConfig,
|
|
authoredOpenClawConfig: snapshot.resolved,
|
|
authProfiles: {
|
|
agentId: configureAgentId,
|
|
store: authStore,
|
|
},
|
|
});
|
|
if (candidates.length === 0) {
|
|
throw new Error("No configurable secret-bearing fields found for this agent scope.");
|
|
}
|
|
|
|
const sourceChoices = toSourceChoices(stagedConfig);
|
|
const hasDerivedCandidates = candidates.some((candidate) => candidate.isDerived === true);
|
|
let showDerivedCandidates = false;
|
|
|
|
while (true) {
|
|
const visibleCandidates = showDerivedCandidates
|
|
? candidates
|
|
: candidates.filter((candidate) => candidate.isDerived !== true);
|
|
const options = visibleCandidates.map((candidate) => ({
|
|
value: configureCandidateKey(candidate),
|
|
label: candidate.label,
|
|
hint: [
|
|
candidate.configFile === "auth-profiles.json" ? "auth-profiles.json" : "openclaw.json",
|
|
candidate.isDerived === true ? "derived" : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" | "),
|
|
}));
|
|
options.push({
|
|
value: "__create_auth_profile__",
|
|
label: "Create auth profile mapping",
|
|
hint: `Add a new auth-profiles target for agent ${configureAgentId}`,
|
|
});
|
|
if (hasDerivedCandidates) {
|
|
options.push({
|
|
value: "__toggle_derived__",
|
|
label: showDerivedCandidates ? "Hide derived targets" : "Show derived targets",
|
|
hint: showDerivedCandidates
|
|
? "Show only fields authored directly in config"
|
|
: "Include normalized/derived aliases",
|
|
});
|
|
}
|
|
if (selectedByPath.size > 0) {
|
|
options.unshift({
|
|
value: "__done__",
|
|
label: "Done",
|
|
hint: "Finish and run preflight",
|
|
});
|
|
}
|
|
|
|
const selectedPath = assertNoCancel(
|
|
await select({
|
|
message: "Select credential field",
|
|
options,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
|
|
if (selectedPath === "__done__") {
|
|
break;
|
|
}
|
|
if (selectedPath === "__create_auth_profile__") {
|
|
const createdCandidate = await promptNewAuthProfileCandidate(configureAgentId);
|
|
const key = configureCandidateKey(createdCandidate);
|
|
const existingIndex = candidates.findIndex((entry) => configureCandidateKey(entry) === key);
|
|
if (existingIndex >= 0) {
|
|
candidates[existingIndex] = createdCandidate;
|
|
} else {
|
|
candidates.push(createdCandidate);
|
|
}
|
|
continue;
|
|
}
|
|
if (selectedPath === "__toggle_derived__") {
|
|
showDerivedCandidates = !showDerivedCandidates;
|
|
continue;
|
|
}
|
|
|
|
const candidate = visibleCandidates.find(
|
|
(entry) => configureCandidateKey(entry) === selectedPath,
|
|
);
|
|
if (!candidate) {
|
|
throw new Error(`Unknown configure target: ${selectedPath}`);
|
|
}
|
|
const candidateKey = configureCandidateKey(candidate);
|
|
const priorSelection = selectedByPath.get(candidateKey);
|
|
const existingRef = priorSelection?.ref ?? candidate.existingRef;
|
|
const sourceInitialValue =
|
|
existingRef && hasSourceChoice(sourceChoices, existingRef.source)
|
|
? existingRef.source
|
|
: undefined;
|
|
|
|
const source = assertNoCancel(
|
|
await select({
|
|
message: "Secret source",
|
|
options: sourceChoices,
|
|
initialValue: sourceInitialValue,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
) as SecretRefSource;
|
|
|
|
const defaultAlias = resolveDefaultSecretProviderAlias(stagedConfig, source, {
|
|
preferFirstProviderForSource: true,
|
|
});
|
|
const providerInitialValue =
|
|
existingRef?.source === source ? existingRef.provider : defaultAlias;
|
|
const provider = assertNoCancel(
|
|
await text({
|
|
message: "Provider alias",
|
|
initialValue: providerInitialValue,
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return "Required";
|
|
}
|
|
if (!isValidSecretProviderAlias(trimmed)) {
|
|
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
const providerAlias = String(provider).trim();
|
|
const suggestedIdFromExistingRef =
|
|
existingRef?.source === source ? existingRef.id : undefined;
|
|
let suggestedId = suggestedIdFromExistingRef;
|
|
if (!suggestedId && source === "env") {
|
|
suggestedId = resolveSuggestedEnvSecretId(candidate);
|
|
}
|
|
if (!suggestedId && source === "file") {
|
|
const configuredProvider = stagedConfig.secrets?.providers?.[providerAlias];
|
|
if (configuredProvider?.source === "file" && configuredProvider.mode === "singleValue") {
|
|
suggestedId = "value";
|
|
}
|
|
}
|
|
const id = assertNoCancel(
|
|
await text({
|
|
message: "Secret id",
|
|
initialValue: suggestedId,
|
|
validate: (value) => {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed) {
|
|
return "Required";
|
|
}
|
|
if (source === "exec" && !isValidExecSecretRefId(trimmed)) {
|
|
return formatExecSecretRefIdValidationMessage();
|
|
}
|
|
return undefined;
|
|
},
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
const ref: SecretRef = {
|
|
source,
|
|
provider: providerAlias,
|
|
id: String(id).trim(),
|
|
};
|
|
const resolved = await resolveSecretRefValue(ref, {
|
|
config: stagedConfig,
|
|
env,
|
|
});
|
|
assertExpectedResolvedSecretValue({
|
|
value: resolved,
|
|
expected: candidate.expectedResolvedValue,
|
|
errorMessage:
|
|
candidate.expectedResolvedValue === "string"
|
|
? `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a non-empty string.`
|
|
: `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a supported value type.`,
|
|
});
|
|
|
|
const next = {
|
|
...candidate,
|
|
ref,
|
|
};
|
|
selectedByPath.set(candidateKey, next);
|
|
|
|
const addMore = assertNoCancel(
|
|
await confirm({
|
|
message: "Configure another credential?",
|
|
initialValue: true,
|
|
}),
|
|
"Secrets configure cancelled.",
|
|
);
|
|
if (!addMore) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasConfigurePlanChanges({ selectedTargets: selectedByPath, providerChanges })) {
|
|
throw new Error("No secrets changes were selected.");
|
|
}
|
|
|
|
const plan = buildSecretsConfigurePlan({
|
|
selectedTargets: selectedByPath,
|
|
providerChanges,
|
|
});
|
|
|
|
const preflight = await runSecretsApply({
|
|
plan,
|
|
env,
|
|
write: false,
|
|
});
|
|
|
|
return { plan, preflight };
|
|
}
|