From 06290b49b2ceaea34aee54a93fd56f352498c3e1 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:17:31 -0600 Subject: [PATCH] feat(secrets): finalize mode rename and validated exec docs --- docs/cli/index.md | 2 +- docs/cli/secrets.md | 15 +- docs/gateway/configuration-reference.md | 7 +- docs/gateway/secrets.md | 128 ++- src/cli/secrets-cli.ts | 21 +- .../auth-choice.apply-helpers.test.ts | 2 +- src/commands/auth-choice.apply-helpers.ts | 10 +- src/commands/auth-choice.test.ts | 2 +- src/commands/onboard-custom.test.ts | 2 +- src/config/config.secrets-schema.test.ts | 6 +- src/config/types.secrets.ts | 2 +- src/config/zod-schema.core.ts | 4 +- src/secrets/apply.test.ts | 59 ++ src/secrets/apply.ts | 59 +- src/secrets/configure.ts | 792 ++++++++++++++++-- src/secrets/plan.ts | 94 ++- src/secrets/ref-contract.ts | 4 +- src/secrets/resolve.test.ts | 12 +- src/secrets/resolve.ts | 12 +- src/secrets/runtime.test.ts | 4 +- 20 files changed, 1109 insertions(+), 128 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index 3e0cdd7eb0c..bf7218146ac 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -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 ` — apply a previously generated plan (`--dry-run` supported). ## Plugins diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index bd498e8af4f..a1a271c6ad1 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -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`. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8fcad884ce8..a7f1c378bc2 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. --- diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index cfffa27c23a..7269e4d4eb5 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -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 diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index cc579d4d7bd..05cc38afe03 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -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 ", "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}`); } diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts index 4dcf60b3fb9..471123621e1 100644 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -232,7 +232,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => { filemain: { source: "file", path: "/tmp/does-not-exist-secrets.json", - mode: "jsonPointer", + mode: "json", }, }, }, diff --git a/src/commands/auth-choice.apply-helpers.ts b/src/commands/auth-choice.apply-helpers.ts index e455b15bf26..52e019aae19 100644 --- a/src/commands/auth-choice.apply-helpers.ts +++ b/src/commands/auth-choice.apply-helpers.ts @@ -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; }, diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 49530ae84c4..bfadf93f074 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -775,7 +775,7 @@ describe("applyAuthChoice", () => { filemain: { source: "file", path: "/tmp/openclaw-missing-secrets.json", - mode: "jsonPointer", + mode: "json", }, }, }, diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index 27043a30811..55be1b89dc3 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -268,7 +268,7 @@ describe("promptCustomApiConfig", () => { filemain: { source: "file", path: "/tmp/openclaw-missing-provider.json", - mode: "jsonPointer", + mode: "json", }, }, }, diff --git a/src/config/config.secrets-schema.test.ts b/src/config/config.secrets-schema.test.ts index 623d8e5405c..43379464d74 100644 --- a/src/config/config.secrets-schema.test.ts +++ b/src/config/config.secrets-schema.test.ts @@ -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", }, }, }, diff --git a/src/config/types.secrets.ts b/src/config/types.secrets.ts index 44c35623498..547c049875c 100644 --- a/src/config/types.secrets.ts +++ b/src/config/types.secrets.ts @@ -106,7 +106,7 @@ export type EnvSecretProviderConfig = { allowlist?: string[]; }; -export type FileSecretProviderMode = "raw" | "jsonPointer"; +export type FileSecretProviderMode = "singleValue" | "json"; export type FileSecretProviderConfig = { source: "file"; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 07a0de83ce6..e2a9f45cf53 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -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() diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index f497f1ef1ad..9763594c42c 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -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; + }; + }; + expect(nextConfig.secrets?.providers?.fileold).toBeUndefined(); + expect(nextConfig.secrets?.providers?.filemain).toEqual({ + source: "file", + path: "/tmp/new-secrets.json", + mode: "json", + }); + }); }); diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 9ef60687ccb..005de3e9f26 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -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 | 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(); const providerTargets = new Set(); + 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, diff --git a/src/secrets/configure.ts b/src/secrets/configure.ts index 3bda5704871..df6d48e4810 100644 --- a/src/secrets/configure.ts +++ b/src/secrets/configure.ts @@ -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 { + 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 | 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(value: T | symbol, message: string): T { return value; } +async function promptProviderAlias(params: { existingAliases: Set }): Promise { + 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 { + 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, +): Promise> { + 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, +): Promise> { + 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 { + 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, +): Promise> { + 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 { + 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 { + 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; + deletes: string[]; +} { + const originalProviders = getSecretProviders(params.original); + const nextProviders = getSecretProviders(params.next); + + const upserts: Record = {}; + 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 { 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(); - 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, diff --git a/src/secrets/plan.ts b/src/secrets/plan.ts index 5e4a1bba0ef..df25a690155 100644 --- a/src/secrets/plan.ts +++ b/src/secrets/plan.ts @@ -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; + 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 { + 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; } diff --git a/src/secrets/ref-contract.ts b/src/secrets/ref-contract.ts index 641dbb1564d..5366b814999 100644 --- a/src/secrets/ref-contract.ts +++ b/src/secrets/ref-contract.ts @@ -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("/")) { diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index 990ce508b0d..b47a788dcf0 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -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, }, }, diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 22c61f113c2..50118bffa76 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -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(); - 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); diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 9f2f02e82f7..00d11c7392a 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -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", }, }, },