feat(secrets): finalize mode rename and validated exec docs

This commit is contained in:
joshavant
2026-02-25 23:17:31 -06:00
committed by Peter Steinberger
parent ba2eb583c0
commit 06290b49b2
20 changed files with 1109 additions and 128 deletions

View File

@@ -271,7 +271,7 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
- `openclaw secrets configure` — interactive helper to build SecretRef plan and preflight/apply safely.
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
## Plugins

View File

@@ -53,15 +53,28 @@ Exit behavior:
## Configure (interactive helper)
Build SecretRef changes interactively, run preflight, and optionally apply:
Build provider + SecretRef changes interactively, run preflight, and optionally apply:
```bash
openclaw secrets configure
openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json
openclaw secrets configure --apply --yes
openclaw secrets configure --providers-only
openclaw secrets configure --skip-provider-setup
openclaw secrets configure --json
```
Flow:
- Provider setup first (`add/edit/remove` for `secrets.providers` aliases).
- Credential mapping second (select fields and assign `{source, provider, id}` refs).
- Preflight and optional apply last.
Flags:
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
Notes:
- `configure` targets secret-bearing fields in `openclaw.json`.

View File

@@ -2423,7 +2423,7 @@ Validation:
filemain: {
source: "file",
path: "~/.openclaw/secrets.json",
mode: "jsonPointer",
mode: "json",
timeoutMs: 5000,
},
vault: {
@@ -2443,8 +2443,9 @@ Validation:
Notes:
- `file` provider supports `mode: "jsonPointer"` and `mode: "raw"` (`id` must be `"value"` in raw mode).
- `exec` provider requires an absolute `command` path and uses protocol payloads on stdin/stdout.
- `file` provider supports `mode: "json"` and `mode: "singleValue"` (`id` must be `"value"` in singleValue mode).
- `exec` provider requires an absolute non-symlink `command` path and uses protocol payloads on stdin/stdout.
- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`.
- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only.
---

View File

@@ -87,7 +87,7 @@ Define providers under `secrets.providers`:
filemain: {
source: "file",
path: "~/.openclaw/secrets.json",
mode: "jsonPointer", // or "raw"
mode: "json", // or "singleValue"
},
vault: {
source: "exec",
@@ -119,13 +119,14 @@ Define providers under `secrets.providers`:
### File provider
- Reads local file from `path`.
- `mode: "jsonPointer"` expects JSON object payload and resolves `id` as pointer.
- `mode: "raw"` expects ref id `"value"` and returns file contents.
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
- Path must pass ownership/permission checks.
### Exec provider
- Runs configured absolute binary path, no shell.
- `command` must point to a regular file (not a symlink).
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
- Request payload (stdin):
@@ -149,6 +150,121 @@ Optional per-id errors:
}
```
## Validated exec integration examples
The patterns below were validated end-to-end with `openclaw secrets audit --json` and `unresolvedRefCount=0`.
### 1Password (`op`)
1. Create a wrapper script (non-symlink command path):
```bash
cat >/usr/local/libexec/openclaw/op-openai.sh <<'SH'
#!/bin/sh
exec /opt/homebrew/bin/op read 'op://Personal/OpenClaw QA API Key/password'
SH
chmod 700 /usr/local/libexec/openclaw/op-openai.sh
```
2. Configure provider + ref:
```json5
{
secrets: {
providers: {
onepassword_openai: {
source: "exec",
command: "/usr/local/libexec/openclaw/op-openai.sh",
passEnv: ["HOME"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "exec", provider: "onepassword_openai", id: "value" },
},
},
},
}
```
### HashiCorp Vault CLI
1. Wrapper script:
```bash
cat >/usr/local/libexec/openclaw/vault-openai.sh <<'SH'
#!/bin/sh
exec /opt/homebrew/opt/vault/bin/vault kv get -field=OPENAI_API_KEY secret/openclaw
SH
chmod 700 /usr/local/libexec/openclaw/vault-openai.sh
```
2. Provider + ref:
```json5
{
secrets: {
providers: {
vault_openai: {
source: "exec",
command: "/usr/local/libexec/openclaw/vault-openai.sh",
passEnv: ["VAULT_ADDR", "VAULT_TOKEN"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "exec", provider: "vault_openai", id: "value" },
},
},
},
}
```
### `sops`
1. Wrapper script:
```bash
cat >/usr/local/libexec/openclaw/sops-openai.sh <<'SH'
#!/bin/sh
exec /opt/homebrew/bin/sops -d --extract '["providers"]["openai"]["apiKey"]' /path/to/secrets.enc.json
SH
chmod 700 /usr/local/libexec/openclaw/sops-openai.sh
```
2. Provider + ref:
```json5
{
secrets: {
providers: {
sops_openai: {
source: "exec",
command: "/usr/local/libexec/openclaw/sops-openai.sh",
passEnv: ["SOPS_AGE_KEY_FILE"],
jsonOnly: false,
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "exec", provider: "sops_openai", id: "value" },
},
},
},
}
```
## In-scope fields (v1)
### `~/.openclaw/openclaw.json`
@@ -231,11 +347,17 @@ Findings include:
Interactive helper that:
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
- lets you select secret-bearing fields in `openclaw.json`
- captures SecretRef details (`source`, `provider`, `id`)
- runs preflight resolution
- can apply immediately
Helpful modes:
- `openclaw secrets configure --providers-only`
- `openclaw secrets configure --skip-provider-setup`
`configure` apply defaults to:
- scrub matching static creds from `auth-profiles.json` for targeted providers

View File

@@ -20,6 +20,8 @@ type SecretsConfigureOptions = {
apply?: boolean;
yes?: boolean;
planOut?: string;
providersOnly?: boolean;
skipProviderSetup?: boolean;
json?: boolean;
};
type SecretsApplyOptions = {
@@ -112,14 +114,23 @@ export function registerSecretsCli(program: Command) {
secrets
.command("configure")
.description("Interactive SecretRef helper with preflight validation")
.description("Interactive secrets helper (provider setup + SecretRef mapping + preflight)")
.option("--apply", "Apply changes immediately after preflight", false)
.option("--yes", "Skip apply confirmation prompt", false)
.option("--providers-only", "Configure secrets.providers only, skip credential mapping", false)
.option(
"--skip-provider-setup",
"Skip provider setup and only map credential fields to existing providers",
false,
)
.option("--plan-out <path>", "Write generated plan JSON to a file")
.option("--json", "Output JSON", false)
.action(async (opts: SecretsConfigureOptions) => {
try {
const configured = await runSecretsConfigureInteractive();
const configured = await runSecretsConfigureInteractive({
providersOnly: Boolean(opts.providersOnly),
skipProviderSetup: Boolean(opts.skipProviderSetup),
});
if (opts.planOut) {
fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8");
}
@@ -143,7 +154,11 @@ export function registerSecretsCli(program: Command) {
defaultRuntime.log(`- warning: ${warning}`);
}
}
defaultRuntime.log(`Plan targets: ${configured.plan.targets.length}`);
const providerUpserts = Object.keys(configured.plan.providerUpserts ?? {}).length;
const providerDeletes = configured.plan.providerDeletes?.length ?? 0;
defaultRuntime.log(
`Plan: targets=${configured.plan.targets.length}, providerUpserts=${providerUpserts}, providerDeletes=${providerDeletes}.`,
);
if (opts.planOut) {
defaultRuntime.log(`Plan written to ${opts.planOut}`);
}

View File

@@ -232,7 +232,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
filemain: {
source: "file",
path: "/tmp/does-not-exist-secrets.json",
mode: "jsonPointer",
mode: "json",
},
},
},

View File

@@ -176,11 +176,11 @@ async function resolveApiKeyRefForOnboarding(params: {
}
const idPrompt =
providerEntry.source === "file"
? "Secret id (JSON pointer for jsonPointer mode, or 'value' for raw mode)"
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
: "Secret id for the exec provider";
const idDefault =
providerEntry.source === "file"
? providerEntry.mode === "raw"
? providerEntry.mode === "singleValue"
? "value"
: defaultFilePointer
: `${params.provider}/apiKey`;
@@ -195,17 +195,17 @@ async function resolveApiKeyRefForOnboarding(params: {
}
if (
providerEntry.source === "file" &&
providerEntry.mode !== "raw" &&
providerEntry.mode !== "singleValue" &&
!isValidFileSecretRefId(candidate)
) {
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
}
if (
providerEntry.source === "file" &&
providerEntry.mode === "raw" &&
providerEntry.mode === "singleValue" &&
candidate !== "value"
) {
return 'Raw file mode expects id "value".';
return 'singleValue mode expects id "value".';
}
return undefined;
},

View File

@@ -775,7 +775,7 @@ describe("applyAuthChoice", () => {
filemain: {
source: "file",
path: "/tmp/openclaw-missing-secrets.json",
mode: "jsonPointer",
mode: "json",
},
},
},

View File

@@ -268,7 +268,7 @@ describe("promptCustomApiConfig", () => {
filemain: {
source: "file",
path: "/tmp/openclaw-missing-provider.json",
mode: "jsonPointer",
mode: "json",
},
},
},

View File

@@ -10,7 +10,7 @@ describe("config secret refs schema", () => {
filemain: {
source: "file",
path: "~/.openclaw/secrets.json",
mode: "jsonPointer",
mode: "json",
timeoutMs: 10_000,
},
vault: {
@@ -65,14 +65,14 @@ describe("config secret refs schema", () => {
expect(result.ok).toBe(true);
});
it('accepts file refs with id "value" for raw mode providers', () => {
it('accepts file refs with id "value" for singleValue mode providers', () => {
const result = validateConfigObjectRaw({
secrets: {
providers: {
rawfile: {
source: "file",
path: "~/.openclaw/token.txt",
mode: "raw",
mode: "singleValue",
},
},
},

View File

@@ -106,7 +106,7 @@ export type EnvSecretProviderConfig = {
allowlist?: string[];
};
export type FileSecretProviderMode = "raw" | "jsonPointer";
export type FileSecretProviderMode = "singleValue" | "json";
export type FileSecretProviderConfig = {
source: "file";

View File

@@ -50,7 +50,7 @@ const FileSecretRefSchema = z
.string()
.refine(
isValidFileSecretRefId,
'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for raw mode.',
'File secret reference id must be an absolute JSON pointer (example: "/providers/openai/apiKey"), or "value" for singleValue mode.',
),
})
.strict();
@@ -92,7 +92,7 @@ const SecretsFileProviderSchema = z
.object({
source: z.literal("file"),
path: z.string().min(1),
mode: z.union([z.literal("raw"), z.literal("jsonPointer")]).optional(),
mode: z.union([z.literal("singleValue"), z.literal("json")]).optional(),
timeoutMs: z.number().int().positive().max(120000).optional(),
maxBytes: z
.number()

View File

@@ -176,4 +176,63 @@ describe("secrets apply", () => {
expect(second.changed).toBe(false);
expect(second.changedFiles).toEqual([]);
});
it("applies provider upserts and deletes from plan", async () => {
await fs.writeFile(
configPath,
`${JSON.stringify(
{
secrets: {
providers: {
envmain: { source: "env" },
fileold: { source: "file", path: "/tmp/old-secrets.json", mode: "json" },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
providerUpserts: {
filemain: {
source: "file",
path: "/tmp/new-secrets.json",
mode: "json",
},
},
providerDeletes: ["fileold"],
targets: [],
};
const result = await runSecretsApply({ plan, env, write: true });
expect(result.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
secrets?: {
providers?: Record<string, unknown>;
};
};
expect(nextConfig.secrets?.providers?.fileold).toBeUndefined();
expect(nextConfig.secrets?.providers?.filemain).toEqual({
source: "file",
path: "/tmp/new-secrets.json",
mode: "json",
});
});
});

View File

@@ -8,6 +8,7 @@ import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveStateDir, type OpenClawConfig } from "../config/config.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { SecretProviderConfig } from "../config/types.secrets.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { createSecretsConfigIO } from "./config-io.js";
import { type SecretsApplyPlan, normalizeSecretsPlanOptions } from "./plan.js";
@@ -209,6 +210,48 @@ function resolveGoogleChatRefPath(pathLabel: string): string {
throw new Error(`Google Chat target path must end with ".serviceAccount": ${pathLabel}`);
}
function applyProviderPlanMutations(params: {
config: OpenClawConfig;
upserts: Record<string, SecretProviderConfig> | undefined;
deletes: string[] | undefined;
}): boolean {
const currentProviders = isRecord(params.config.secrets?.providers)
? structuredClone(params.config.secrets?.providers)
: {};
let changed = false;
for (const providerAlias of params.deletes ?? []) {
if (!Object.prototype.hasOwnProperty.call(currentProviders, providerAlias)) {
continue;
}
delete currentProviders[providerAlias];
changed = true;
}
for (const [providerAlias, providerConfig] of Object.entries(params.upserts ?? {})) {
const previous = currentProviders[providerAlias];
if (isDeepStrictEqual(previous, providerConfig)) {
continue;
}
currentProviders[providerAlias] = structuredClone(providerConfig);
changed = true;
}
if (!changed) {
return false;
}
params.config.secrets ??= {};
if (Object.keys(currentProviders).length === 0) {
if ("providers" in params.config.secrets) {
delete params.config.secrets.providers;
}
return true;
}
params.config.secrets.providers = currentProviders;
return true;
}
async function projectPlanState(params: {
plan: SecretsApplyPlan;
env: NodeJS.ProcessEnv;
@@ -225,6 +268,16 @@ async function projectPlanState(params: {
const warnings: string[] = [];
const scrubbedValues = new Set<string>();
const providerTargets = new Set<string>();
const configPath = resolveUserPath(snapshot.path);
const providerConfigChanged = applyProviderPlanMutations({
config: nextConfig,
upserts: params.plan.providerUpserts,
deletes: params.plan.providerDeletes,
});
if (providerConfigChanged) {
changedFiles.add(configPath);
}
for (const target of params.plan.targets) {
if (target.type === "channels.googlechat.serviceAccount") {
@@ -236,7 +289,7 @@ async function projectPlanState(params: {
const wroteRef = setByDotPath(nextConfig, refPath, target.ref);
const deletedLegacy = deleteByDotPath(nextConfig, target.path);
if (wroteRef || deletedLegacy) {
changedFiles.add(resolveUserPath(snapshot.path));
changedFiles.add(configPath);
}
continue;
}
@@ -247,7 +300,7 @@ async function projectPlanState(params: {
}
const wroteRef = setByDotPath(nextConfig, target.path, target.ref);
if (wroteRef) {
changedFiles.add(resolveUserPath(snapshot.path));
changedFiles.add(configPath);
}
if (target.type === "models.providers.apiKey" && target.providerId) {
providerTargets.add(normalizeProviderId(target.providerId));
@@ -400,7 +453,7 @@ async function projectPlanState(params: {
return {
nextConfig,
configPath: resolveUserPath(snapshot.path),
configPath,
configWriteOptions: writeOptions,
authStoreByPath,
authJsonByPath,

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { confirm, select, text } from "@clack/prompts";
import type { OpenClawConfig } from "../config/config.js";
import type { SecretRef, SecretRefSource } from "../config/types.secrets.js";
import type { SecretProviderConfig, SecretRef, SecretRefSource } from "../config/types.secrets.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { runSecretsApply, type SecretsApplyResult } from "./apply.js";
import { createSecretsConfigIO } from "./config-io.js";
import { type SecretsApplyPlan } from "./plan.js";
@@ -20,6 +23,106 @@ export type SecretsConfigureResult = {
preflight: SecretsApplyResult;
};
const PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
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 buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
const out: ConfigureCandidate[] = [];
const providers = config.models?.providers as Record<string, unknown> | undefined;
@@ -81,7 +184,10 @@ function toSourceChoices(config: OpenClawConfig): Array<{ value: SecretRefSource
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" },
{
value: "env",
label: "env",
},
];
if (hasSource("file")) {
choices.push({ value: "file", label: "file" });
@@ -99,14 +205,505 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
return value;
}
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 (!PROVIDER_ALIAS_PATTERN.test(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 allowlistRaw = assertNoCancel(
await text({
message: "Env allowlist (comma-separated, blank for unrestricted)",
initialValue: base?.allowlist?.join(",") ?? "",
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const allowlist = parseCsv(String(allowlistRaw ?? ""));
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 timeoutMsRaw = assertNoCancel(
await text({
message: "Timeout ms (blank for default)",
initialValue: base?.timeoutMs ? String(base.timeoutMs) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
return "Must be an integer between 1 and 120000";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const maxBytesRaw = assertNoCancel(
await text({
message: "Max bytes (blank for default)",
initialValue: base?.maxBytes ? String(base.maxBytes) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 20 * 1024 * 1024) === undefined) {
return "Must be an integer between 1 and 20971520";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const timeoutMs = parseOptionalPositiveInt(String(timeoutMsRaw ?? ""), 120000);
const maxBytes = parseOptionalPositiveInt(String(maxBytesRaw ?? ""), 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 timeoutMsRaw = assertNoCancel(
await text({
message: "Timeout ms (blank for default)",
initialValue: base?.timeoutMs ? String(base.timeoutMs) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
return "Must be an integer between 1 and 120000";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const noOutputTimeoutMsRaw = assertNoCancel(
await text({
message: "No-output timeout ms (blank for default)",
initialValue: base?.noOutputTimeoutMs ? String(base.noOutputTimeoutMs) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
return "Must be an integer between 1 and 120000";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const maxOutputBytesRaw = assertNoCancel(
await text({
message: "Max output bytes (blank for default)",
initialValue: base?.maxOutputBytes ? String(base.maxOutputBytes) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 20 * 1024 * 1024) === undefined) {
return "Must be an integer between 1 and 20971520";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const jsonOnly = assertNoCancel(
await confirm({
message: "Require JSON-only response?",
initialValue: base?.jsonOnly ?? true,
}),
"Secrets configure cancelled.",
);
const passEnvRaw = assertNoCancel(
await text({
message: "Pass-through env vars (comma-separated, blank for none)",
initialValue: base?.passEnv?.join(",") ?? "",
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
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 args = await parseArgsInput(String(argsRaw ?? ""));
const timeoutMs = parseOptionalPositiveInt(String(timeoutMsRaw ?? ""), 120000);
const noOutputTimeoutMs = parseOptionalPositiveInt(String(noOutputTimeoutMsRaw ?? ""), 120000);
const maxOutputBytes = parseOptionalPositiveInt(
String(maxOutputBytesRaw ?? ""),
20 * 1024 * 1024,
);
const passEnv = parseCsv(String(passEnvRaw ?? ""));
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 } : {}),
...(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);
}
}
}
}
function collectProviderPlanChanges(params: { original: OpenClawConfig; next: OpenClawConfig }): {
upserts: Record<string, SecretProviderConfig>;
deletes: string[];
} {
const originalProviders = getSecretProviders(params.original);
const nextProviders = getSecretProviders(params.next);
const upserts: Record<string, SecretProviderConfig> = {};
const deletes: string[] = [];
for (const [providerAlias, nextProviderConfig] of Object.entries(nextProviders)) {
const current = originalProviders[providerAlias];
if (isDeepStrictEqual(current, nextProviderConfig)) {
continue;
}
upserts[providerAlias] = structuredClone(nextProviderConfig);
}
for (const providerAlias of Object.keys(originalProviders)) {
if (!Object.prototype.hasOwnProperty.call(nextProviders, providerAlias)) {
deletes.push(providerAlias);
}
}
return {
upserts,
deletes: deletes.toSorted(),
};
}
export async function runSecretsConfigureInteractive(
params: {
env?: NodeJS.ProcessEnv;
providersOnly?: boolean;
skipProviderSetup?: boolean;
} = {},
): 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();
@@ -114,97 +711,122 @@ export async function runSecretsConfigureInteractive(
throw new Error("Cannot run interactive secrets configure because config is invalid.");
}
const candidates = buildCandidates(snapshot.config);
if (candidates.length === 0) {
throw new Error("No configurable secret-bearing fields found in openclaw.json.");
const stagedConfig = structuredClone(snapshot.config);
if (!params.skipProviderSetup) {
await configureProvidersInteractive(stagedConfig);
}
const providerChanges = collectProviderPlanChanges({
original: snapshot.config,
next: stagedConfig,
});
const selectedByPath = new Map<string, ConfigureCandidate & { ref: SecretRef }>();
const sourceChoices = toSourceChoices(snapshot.config);
if (!params.providersOnly) {
const candidates = buildCandidates(stagedConfig);
if (candidates.length === 0) {
throw new Error("No configurable secret-bearing fields found in openclaw.json.");
}
while (true) {
const options = candidates.map((candidate) => ({
value: candidate.path,
label: candidate.label,
hint: candidate.path,
}));
if (selectedByPath.size > 0) {
options.unshift({
value: "__done__",
label: "Done",
hint: "Finish and run preflight",
const sourceChoices = toSourceChoices(stagedConfig);
while (true) {
const options = candidates.map((candidate) => ({
value: candidate.path,
label: candidate.label,
hint: candidate.path,
}));
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;
}
const candidate = candidates.find((entry) => entry.path === selectedPath);
if (!candidate) {
throw new Error(`Unknown configure target: ${selectedPath}`);
}
const source = assertNoCancel(
await select({
message: "Secret source",
options: sourceChoices,
}),
"Secrets configure cancelled.",
) as SecretRefSource;
const defaultAlias = resolveDefaultSecretProviderAlias(stagedConfig, source, {
preferFirstProviderForSource: true,
});
}
const provider = assertNoCancel(
await text({
message: "Provider alias",
initialValue: defaultAlias,
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const id = assertNoCancel(
await text({
message: "Secret id",
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"Secrets configure cancelled.",
);
const ref: SecretRef = {
source,
provider: String(provider).trim(),
id: String(id).trim(),
};
const selectedPath = assertNoCancel(
await select({
message: "Select credential field",
options,
}),
"Secrets configure cancelled.",
);
const next = {
...candidate,
ref,
};
selectedByPath.set(candidate.path, next);
if (selectedPath === "__done__") {
break;
}
const candidate = candidates.find((entry) => entry.path === selectedPath);
if (!candidate) {
throw new Error(`Unknown configure target: ${selectedPath}`);
}
const source = assertNoCancel(
await select({
message: "Secret source",
options: sourceChoices,
}),
"Secrets configure cancelled.",
) as SecretRefSource;
const defaultAlias = resolveDefaultSecretProviderAlias(snapshot.config, source, {
preferFirstProviderForSource: true,
});
const provider = assertNoCancel(
await text({
message: "Provider alias",
initialValue: defaultAlias,
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"Secrets configure cancelled.",
);
const id = assertNoCancel(
await text({
message: "Secret id",
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"Secrets configure cancelled.",
);
const ref: SecretRef = {
source,
provider: String(provider).trim(),
id: String(id).trim(),
};
const next = {
...candidate,
ref,
};
selectedByPath.set(candidate.path, next);
const addMore = assertNoCancel(
await confirm({
message: "Configure another credential?",
initialValue: true,
}),
"Secrets configure cancelled.",
);
if (!addMore) {
break;
const addMore = assertNoCancel(
await confirm({
message: "Configure another credential?",
initialValue: true,
}),
"Secrets configure cancelled.",
);
if (!addMore) {
break;
}
}
}
if (selectedByPath.size === 0) {
throw new Error("No secrets were selected.");
if (
selectedByPath.size === 0 &&
Object.keys(providerChanges.upserts).length === 0 &&
providerChanges.deletes.length === 0
) {
throw new Error("No secrets changes were selected.");
}
const plan: SecretsApplyPlan = {
@@ -219,6 +841,10 @@ export async function runSecretsConfigureInteractive(
...(entry.providerId ? { providerId: entry.providerId } : {}),
...(entry.accountId ? { accountId: entry.accountId } : {}),
})),
...(Object.keys(providerChanges.upserts).length > 0
? { providerUpserts: providerChanges.upserts }
: {}),
...(providerChanges.deletes.length > 0 ? { providerDeletes: providerChanges.deletes } : {}),
options: {
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,

View File

@@ -1,4 +1,4 @@
import type { SecretRef } from "../config/types.secrets.js";
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
export type SecretsPlanTargetType =
| "models.providers.apiKey"
@@ -28,6 +28,8 @@ export type SecretsApplyPlan = {
protocolVersion: 1;
generatedAt: string;
generatedBy: "openclaw secrets configure" | "manual";
providerUpserts?: Record<string, SecretProviderConfig>;
providerDeletes?: string[];
targets: SecretsPlanTarget[];
options?: {
scrubEnv?: boolean;
@@ -36,6 +38,72 @@ export type SecretsApplyPlan = {
};
};
const PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
}
function isSecretProviderConfigShape(value: unknown): value is SecretProviderConfig {
if (!isObjectRecord(value) || typeof value.source !== "string") {
return false;
}
if (value.source === "env") {
if (value.allowlist !== undefined && !isStringArray(value.allowlist)) {
return false;
}
return true;
}
if (value.source === "file") {
if (typeof value.path !== "string" || value.path.trim().length === 0) {
return false;
}
if (value.mode !== undefined && value.mode !== "json" && value.mode !== "singleValue") {
return false;
}
return true;
}
if (value.source === "exec") {
if (typeof value.command !== "string" || value.command.trim().length === 0) {
return false;
}
if (value.args !== undefined && !isStringArray(value.args)) {
return false;
}
if (
value.passEnv !== undefined &&
(!Array.isArray(value.passEnv) || !value.passEnv.every((entry) => typeof entry === "string"))
) {
return false;
}
if (
value.trustedDirs !== undefined &&
(!Array.isArray(value.trustedDirs) ||
!value.trustedDirs.every((entry) => typeof entry === "string"))
) {
return false;
}
if (value.env !== undefined) {
if (!isObjectRecord(value.env)) {
return false;
}
if (!Object.values(value.env).every((entry) => typeof entry === "string")) {
return false;
}
}
return true;
}
return false;
}
export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
@@ -67,6 +135,30 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
return false;
}
}
if (typed.providerUpserts !== undefined) {
if (!isObjectRecord(typed.providerUpserts)) {
return false;
}
for (const [providerAlias, providerValue] of Object.entries(typed.providerUpserts)) {
if (!PROVIDER_ALIAS_PATTERN.test(providerAlias)) {
return false;
}
if (!isSecretProviderConfigShape(providerValue)) {
return false;
}
}
}
if (typed.providerDeletes !== undefined) {
if (
!Array.isArray(typed.providerDeletes) ||
typed.providerDeletes.some(
(providerAlias) =>
typeof providerAlias !== "string" || !PROVIDER_ALIAS_PATTERN.test(providerAlias),
)
) {
return false;
}
}
return true;
}

View File

@@ -6,7 +6,7 @@ import {
const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/;
export const RAW_FILE_REF_ID = "value";
export const SINGLE_VALUE_FILE_REF_ID = "value";
export type SecretRefDefaultsCarrier = {
secrets?: {
@@ -53,7 +53,7 @@ export function resolveDefaultSecretProviderAlias(
}
export function isValidFileSecretRefId(value: string): boolean {
if (value === RAW_FILE_REF_ID) {
if (value === SINGLE_VALUE_FILE_REF_ID) {
return true;
}
if (!value.startsWith("/")) {

View File

@@ -37,7 +37,7 @@ describe("secret ref resolver", () => {
expect(value).toBe("sk-env-value");
});
it("resolves file refs in jsonPointer mode", async () => {
it("resolves file refs in json mode", async () => {
if (process.platform === "win32") {
return;
}
@@ -64,7 +64,7 @@ describe("secret ref resolver", () => {
filemain: {
source: "file",
path: filePath,
mode: "jsonPointer",
mode: "json",
},
},
},
@@ -253,11 +253,11 @@ describe("secret ref resolver", () => {
).rejects.toThrow("returned invalid JSON");
});
it("supports file raw mode with id=value", async () => {
it("supports file singleValue mode with id=value", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-raw-"));
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-single-value-"));
cleanupRoots.push(root);
const filePath = path.join(root, "token.txt");
await writeSecureFile(filePath, "raw-token-value\n");
@@ -271,7 +271,7 @@ describe("secret ref resolver", () => {
rawfile: {
source: "file",
path: filePath,
mode: "raw",
mode: "singleValue",
},
},
},
@@ -320,7 +320,7 @@ describe("secret ref resolver", () => {
filemain: {
source: "file",
path: filePath,
mode: "jsonPointer",
mode: "json",
timeoutMs: 5,
},
},

View File

@@ -15,7 +15,7 @@ import { resolveUserPath } from "../utils.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { readJsonPointer } from "./json-pointer.js";
import {
RAW_FILE_REF_ID,
SINGLE_VALUE_FILE_REF_ID,
resolveDefaultSecretProviderAlias,
secretRefKey,
} from "./ref-contract.js";
@@ -194,7 +194,7 @@ async function readFileProviderPayload(params: {
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
}
const text = payload.toString("utf8");
if (params.providerConfig.mode === "raw") {
if (params.providerConfig.mode === "singleValue") {
return text.replace(/\r?\n$/, "");
}
const parsed = JSON.parse(text) as unknown;
@@ -257,13 +257,13 @@ async function resolveFileRefs(params: {
providerConfig: params.providerConfig,
cache: params.cache,
});
const mode = params.providerConfig.mode ?? "jsonPointer";
const mode = params.providerConfig.mode ?? "json";
const resolved = new Map<string, unknown>();
if (mode === "raw") {
if (mode === "singleValue") {
for (const ref of params.refs) {
if (ref.id !== RAW_FILE_REF_ID) {
if (ref.id !== SINGLE_VALUE_FILE_REF_ID) {
throw new Error(
`Raw file provider "${params.providerName}" expects ref id "${RAW_FILE_REF_ID}".`,
`singleValue file provider "${params.providerName}" expects ref id "${SINGLE_VALUE_FILE_REF_ID}".`,
);
}
resolved.set(ref.id, payload);

View File

@@ -115,7 +115,7 @@ describe("secrets runtime snapshot", () => {
default: {
source: "file",
path: secretsPath,
mode: "jsonPointer",
mode: "json",
},
},
defaults: {
@@ -163,7 +163,7 @@ describe("secrets runtime snapshot", () => {
default: {
source: "file",
path: secretsPath,
mode: "jsonPointer",
mode: "json",
},
},
},