openclaw/src/secrets/configure.ts
Josh Avant 806803b7ef
feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)
* 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>
2026-03-03 02:58:20 +00:00

978 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 { 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) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"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 };
}