From e595a8c0ac4baac3d59e30116f2d11dcb67cad31 Mon Sep 17 00:00:00 2001 From: Sally O'Malley Date: Thu, 9 Jul 2026 06:30:12 -0400 Subject: [PATCH] Add Vault SecretRef plugin (#89255) * Add Vault SecretRef plugin Signed-off-by: sallyom * expand Vault setup to registered SecretRef targets Signed-off-by: sallyom * fix(vault): use sdk secret target seam * fix(vault): preserve auth profile target paths * docs(vault): document plugin enable step * fix(vault): make status provider-alias aware * fix(vault): reject noncanonical secret ids * fix(vault): separate resolver timeout deadlines * fix(vault): forward private CA trust settings * fix(secrets): preserve plugin policy boundaries --------- Signed-off-by: sallyom Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com> --- .github/labeler.yml | 6 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/cli/secrets.md | 1 + docs/docs.json | 1 + docs/docs_map.md | 24 + docs/gateway/secrets.md | 1 + docs/plugins/plugin-inventory.md | 4 +- docs/plugins/reference.md | 2 +- docs/plugins/reference/imessage.md | 2 +- docs/plugins/reference/vault.md | 23 + docs/plugins/sdk-subpaths.md | 2 +- docs/plugins/vault.md | 332 ++++++++++ extensions/vault/index.ts | 24 + extensions/vault/npm-shrinkwrap.json | 12 + extensions/vault/openclaw.plugin.json | 47 ++ extensions/vault/package.json | 27 + extensions/vault/src/cli.test.ts | 243 +++++++ extensions/vault/src/cli.ts | 581 ++++++++++++++++ .../vault/src/secret-ref-resolver.test.ts | 625 ++++++++++++++++++ extensions/vault/vault-secret-id.d.ts | 6 + extensions/vault/vault-secret-id.js | 23 + extensions/vault/vault-secret-ref-resolver.js | 294 ++++++++ pnpm-lock.yaml | 6 + scripts/plugin-sdk-surface-report.mjs | 4 +- src/plugin-sdk/secret-ref-runtime.ts | 23 + src/secrets/apply.test.ts | 259 ++++++++ src/secrets/apply.ts | 62 ++ src/secrets/target-registry-query.ts | 27 + src/secrets/target-registry.test.ts | 16 + test/scripts/runtime-postbuild.test.ts | 4 + 30 files changed, 2677 insertions(+), 8 deletions(-) create mode 100644 docs/plugins/reference/vault.md create mode 100644 docs/plugins/vault.md create mode 100644 extensions/vault/index.ts create mode 100644 extensions/vault/npm-shrinkwrap.json create mode 100644 extensions/vault/openclaw.plugin.json create mode 100644 extensions/vault/package.json create mode 100644 extensions/vault/src/cli.test.ts create mode 100644 extensions/vault/src/cli.ts create mode 100644 extensions/vault/src/secret-ref-resolver.test.ts create mode 100644 extensions/vault/vault-secret-id.d.ts create mode 100644 extensions/vault/vault-secret-id.js create mode 100644 extensions/vault/vault-secret-ref-resolver.js diff --git a/.github/labeler.yml b/.github/labeler.yml index f9a70b61019..cb64e7ad0e5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -335,6 +335,12 @@ - changed-files: - any-glob-to-any-file: - "extensions/tokenjuice/**" +"extensions: vault": + - changed-files: + - any-glob-to-any-file: + - "extensions/vault/**" + - "docs/plugins/vault.md" + - "docs/plugins/reference/vault.md" "extensions: webhooks": - changed-files: - any-glob-to-any-file: diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 42df8903875..5de18ee72dd 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -d23999706f07d7e3ad75a2d642d9d3df9aa9d9eccfd162e90ee03745af79d788 plugin-sdk-api-baseline.json -dd8db488da6b6a09f1873834fea1b84c95c61ebeba99c347760d767ea6ee0a79 plugin-sdk-api-baseline.jsonl +52c7a8b045ee89b62266910d1d2db6a379c696ed514aeaa7ca3c6f0dbcc3ff67 plugin-sdk-api-baseline.json +a703333cefbf3b36cc826965592a8de3542ac91cfe9cd8c5447cb748407fece3 plugin-sdk-api-baseline.jsonl diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md index aa6d38568f6..3a7ec1c469f 100644 --- a/docs/cli/secrets.md +++ b/docs/cli/secrets.md @@ -155,3 +155,4 @@ If `audit --check` still reports plaintext findings, update the remaining report - [CLI reference](/cli) - [Secrets management](/gateway/secrets) +- [Vault SecretRefs](/plugins/vault) diff --git a/docs/docs.json b/docs/docs.json index bf6c07857c6..6654d212c94 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1280,6 +1280,7 @@ "plugins/webhooks", "plugins/admin-http-rpc", "plugins/voice-call", + "plugins/vault", "plugins/memory-wiki", "plugins/llama-cpp", "plugins/memory-lancedb", diff --git a/docs/docs_map.md b/docs/docs_map.md index 660ba40df69..9d473caf088 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -6783,6 +6783,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Surface - H2: Related docs +## plugins/reference/vault.md + +- Route: /plugins/reference/vault +- Headings: + - H1: Vault plugin + - H2: Distribution + - H2: Surface + - H2: Related docs + ## plugins/reference/venice.md - Route: /plugins/reference/venice @@ -7157,6 +7166,21 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Tool does not appear after install - H2: See also +## plugins/vault.md + +- Route: /plugins/vault +- Headings: + - H1: Vault SecretRefs + - H2: Before you begin + - H2: Store a provider key in Vault + - H2: Make Vault visible to the Gateway + - H2: Generate and apply a SecretRef plan + - H2: Configure more provider keys + - H2: SecretRef id format + - H2: What OpenClaw stores + - H2: Containers and managed deployments + - H2: Related + ## plugins/voice-call.md - Route: /plugins/voice-call diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 9c535beeba3..e272554a459 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -741,6 +741,7 @@ Some SecretInput unions are easier to configure in raw editor mode than in form - [Authentication](/gateway/authentication) - auth setup - [CLI: secrets](/cli/secrets) - CLI commands +- [Vault SecretRefs](/plugins/vault) - HashiCorp Vault provider setup - [Environment Variables](/help/environment) - environment precedence - [SecretRef Credential Surface](/reference/secretref-credential-surface) - credential surface - [Secrets Apply Plan Contract](/gateway/secrets-plan-contract) - plan contract details diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index f92c09a5dd6..5ce7fe5a45b 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description. ## Core npm package -61 plugins +62 plugins - **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint. @@ -157,6 +157,8 @@ Each entry lists the package, distribution route, and description. - **[tts-local-cli](/plugins/reference/tts-local-cli)** (`@openclaw/tts-local-cli`) - included in OpenClaw. Adds text-to-speech provider support. +- **[vault](/plugins/reference/vault)** (`@openclaw/vault`) - included in OpenClaw. HashiCorp Vault SecretRef provider integration. + - **[vllm](/plugins/reference/vllm)** (`@openclaw/vllm-provider`) - included in OpenClaw. Adds vLLM model provider support to OpenClaw. - **[volcengine](/plugins/reference/volcengine)** (`@openclaw/volcengine-provider`) - included in OpenClaw. Adds Volcengine, Volcengine Plan model provider support to OpenClaw. diff --git a/docs/plugins/reference.md b/docs/plugins/reference.md index 329a9b9100d..e2c4191acac 100644 --- a/docs/plugins/reference.md +++ b/docs/plugins/reference.md @@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and pnpm plugins:inventory:gen ``` -Use [Plugin inventory](/plugins/plugin-inventory) to browse all 133 +Use [Plugin inventory](/plugins/plugin-inventory) to browse all 134 generated plugin reference pages by distribution, package, and description. diff --git a/docs/plugins/reference/imessage.md b/docs/plugins/reference/imessage.md index 93e523caf78..a5abd7371d4 100644 --- a/docs/plugins/reference/imessage.md +++ b/docs/plugins/reference/imessage.md @@ -16,7 +16,7 @@ Adds the iMessage channel surface for sending and receiving OpenClaw messages. ## Surface -channels: imessage +channels: imessage; skills ## Related docs diff --git a/docs/plugins/reference/vault.md b/docs/plugins/reference/vault.md new file mode 100644 index 00000000000..b59b7cf789e --- /dev/null +++ b/docs/plugins/reference/vault.md @@ -0,0 +1,23 @@ +--- +summary: "HashiCorp Vault SecretRef provider integration." +read_when: + - You are installing, configuring, or auditing the vault plugin +title: "Vault plugin" +--- + +# Vault plugin + +HashiCorp Vault SecretRef provider integration. + +## Distribution + +- Package: `@openclaw/vault` +- Install route: included in OpenClaw + +## Surface + +plugin + +## Related docs + +- [vault](/plugins/vault) diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 6244919039d..ed011d750ab 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -227,7 +227,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/channel-secret-runtime` | Deprecated broad secret-contract surface (`collectSimpleChannelFieldAssignments`, `getChannelSurface`, `pushAssignment`, secret target types); prefer the focused subpaths below | | `plugin-sdk/channel-secret-basic-runtime` | Narrow secret-contract exports for non-TTS channel/plugin secret surfaces | | `plugin-sdk/channel-secret-tts-runtime` | Narrow nested channel TTS secret assignment helpers | - | `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing | + | `plugin-sdk/secret-ref-runtime` | Narrow SecretRef typing, resolution, and plan-target path lookup for secret-contract/config parsing | | `plugin-sdk/secret-provider-integration` | Type-only SecretRef provider integration manifest and preset contracts for plugins that publish external secret provider presets | | `plugin-sdk/security-runtime` | Deprecated broad barrel for trust, DM gating, root-bounded file/path helpers including create-only writes, sync/async atomic file replacement, sibling temp writes, cross-device move fallback, private file-store helpers, symlink-parent guards, external-content, sensitive text redaction, constant-time secret comparison, and secret-collection helpers; prefer focused security/SSRF/secret subpaths | | `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers | diff --git a/docs/plugins/vault.md b/docs/plugins/vault.md new file mode 100644 index 00000000000..91de504fcc9 --- /dev/null +++ b/docs/plugins/vault.md @@ -0,0 +1,332 @@ +--- +summary: "Use the bundled Vault plugin to resolve SecretRefs from HashiCorp Vault" +read_when: + - You want OpenClaw to read API keys from HashiCorp Vault + - You are setting up SecretRefs on a local machine or server + - You need to configure Vault-backed model provider credentials +title: "Vault SecretRefs" +--- + +# Vault SecretRefs + +The bundled Vault plugin lets OpenClaw resolve `exec` SecretRefs from +HashiCorp Vault at Gateway startup and reload time. OpenClaw stores Vault +references in config, keeps resolved values in the in-memory secrets snapshot, +and does not write the resolved API keys back to `openclaw.json`. + +Use this when you already run Vault or want model provider keys to live outside +OpenClaw config files. For the SecretRef runtime model, see +[Secrets management](/gateway/secrets). + +## Before you begin + +You need: + +- OpenClaw with the bundled `vault` plugin available +- a reachable Vault server +- Vault auth that can produce a client token with read access to the secret + paths OpenClaw should resolve +- the environment that starts the Gateway must include `VAULT_ADDR` and either + `VAULT_TOKEN`, `OPENCLAW_VAULT_AUTH_METHOD=token_file` with `VAULT_TOKEN_FILE`, + or a configured JWT/Kubernetes login + +The resolver talks to Vault over HTTP from Node. The Gateway does not need the +Vault CLI to resolve SecretRefs. + +Enable the bundled plugin before running the `openclaw vault` commands: + +```bash +openclaw plugins enable vault +``` + +## Store a provider key in Vault + +OpenClaw defaults to KV v2 mounted at `secret`, matching Vault dev-server +examples. For production Vault, set `OPENCLAW_VAULT_KV_MOUNT` to your actual KV +mount path before creating SecretRef ids. With the OpenClaw defaults, this +SecretRef id: + +```text +providers/openrouter/apiKey +``` + +reads this Vault field: + +```text +secret/data/providers/openrouter -> apiKey +``` + +One way to create it with the Vault CLI is: + +```bash +export OPENROUTER_API_KEY= +vault kv put secret/providers/openrouter apiKey="$OPENROUTER_API_KEY" +``` + +Use a scoped client token for OpenClaw, not a root token. For the default KV v2 +layout, a minimal policy for model provider keys looks like: + +```hcl +path "secret/data/providers/*" { + capabilities = ["read"] +} +``` + +## Make Vault visible to the Gateway + +For an uncontainerized local Gateway, export Vault settings in the same shell +that starts OpenClaw. The default auth method reads a Vault client token from +`VAULT_TOKEN`: + +```bash +export VAULT_ADDR=https://vault.example.com +export VAULT_TOKEN= +``` + +If Vault Agent writes a token sink file, use token-file auth: + +```bash +export VAULT_ADDR=https://vault.example.com +export OPENCLAW_VAULT_AUTH_METHOD=token_file +export VAULT_TOKEN_FILE=/vault/secrets/token +``` + +For a Vault server signed by a private CA, either install that CA in the host +trust store and enable Node system trust: + +```bash +export NODE_USE_SYSTEM_CA=1 +``` + +Or provide a PEM bundle directly: + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/vault-ca.pem +``` + +These variables must be present when OpenClaw starts. The Vault plugin forwards +them to its resolver process. + +For non-interactive JWT auth, use a workload JWT file and a Vault role of type +`jwt`: + +```bash +export VAULT_ADDR=https://vault.example.com +export OPENCLAW_VAULT_AUTH_METHOD=jwt +export OPENCLAW_VAULT_AUTH_MOUNT=jwt +export OPENCLAW_VAULT_AUTH_ROLE=openclaw +export OPENCLAW_VAULT_JWT_FILE=/var/run/secrets/tokens/vault +``` + +The JWT file should be a projected workload token, such as a Kubernetes service account +token with an audience accepted by the Vault role. +Interactive OIDC browser login is useful for humans, but Gateway runtime needs +non-interactive JWT login or a token file. + +For Vault's Kubernetes auth method, use `kubernetes`. This is intended for +Gateways running as Pods; the default mount is `kubernetes`, and the default JWT +file is the standard service account token path: + +```bash +export VAULT_ADDR=https://vault.example.com +export OPENCLAW_VAULT_AUTH_METHOD=kubernetes +export OPENCLAW_VAULT_AUTH_ROLE=openclaw +``` + +Set `OPENCLAW_VAULT_AUTH_MOUNT` only when Vault mounted Kubernetes auth somewhere +other than `auth/kubernetes`. Set `OPENCLAW_VAULT_JWT_FILE` only when the service +account token is projected at a custom path. + +Optional settings: + +```bash +export VAULT_NAMESPACE= +export OPENCLAW_VAULT_KV_MOUNT=secret +export OPENCLAW_VAULT_KV_VERSION=2 +``` + +Check what the current shell can see: + +```bash +openclaw vault status +``` + +When more than one Vault-backed secret provider is configured, select one by +alias: + +```bash +openclaw vault status --provider-alias corp-vault +``` + +`openclaw vault status` never prints `VAULT_TOKEN`; it reports only whether the +token, token file, and JWT file are set. + + +If the Gateway runs as a service, LaunchAgent, systemd unit, scheduled task, or +container, that runtime environment must receive the same Vault variables. +Setting variables in an interactive shell only proves that shell, not the +already-running Gateway. + + +## Generate and apply a SecretRef plan + +Create a plan that maps OpenRouter's model provider API key to Vault: + +```bash +openclaw vault setup \ + --plan-out ./vault-secrets-plan.json \ + --openrouter-id providers/openrouter/apiKey +``` + +Apply and verify the plan: + +```bash +openclaw secrets apply --from ./vault-secrets-plan.json --dry-run --allow-exec +openclaw secrets apply --from ./vault-secrets-plan.json --allow-exec +openclaw secrets audit --check --allow-exec +openclaw secrets reload +``` + +Use `--allow-exec` because the Vault plugin resolves through an OpenClaw-managed +exec SecretRef provider. + +If the Gateway is not running yet, start it normally after applying the plan +instead of running `openclaw secrets reload`. + +## Configure more provider keys + +Built-in shortcuts: + +```bash +openclaw vault setup --openai-id providers/openai/apiKey +openclaw vault setup --anthropic-id providers/anthropic/apiKey +openclaw vault setup --openrouter-id providers/openrouter/apiKey +``` + +Multiple provider keys in one plan: + +```bash +openclaw vault setup \ + --plan-out ./vault-secrets-plan.json \ + --openai-id providers/openai/apiKey \ + --anthropic-id providers/anthropic/apiKey \ + --openrouter-id providers/openrouter/apiKey +``` + +Bundled providers without shortcuts, or already-configured OpenAI-compatible and +custom model providers, use `--provider-key`: + +```bash +openclaw vault setup \ + --plan-out ./vault-secrets-plan.json \ + --provider-key local-openai=providers/local-openai/apiKey \ + --provider-key groq=providers/groq/apiKey +``` + +Each `--provider-key ` writes a SecretRef to +`models.providers..apiKey`. For custom providers, it does not create +the provider's `baseUrl`, `api`, or `models` settings; configure those first. + +Use `--target ` for any known SecretRef target path: + +```bash +openclaw vault setup \ + --target channels.telegram.botToken=channels/telegram/botToken \ + --target models.providers.openai.headers.x-api-key=providers/openai/proxyKey \ + --target auth-profiles:main:profiles.openai.key=providers/openai/apiKey +``` + +Bare target paths apply to `openclaw.json`. Use +`auth-profiles::` for existing `auth-profiles.json` targets. +The target path must be a registered OpenClaw SecretRef target. The setup +command does not create arbitrary named secrets in OpenClaw; Vault remains the +secret store, and OpenClaw stores SecretRefs only on supported config fields. + +## SecretRef id format + +Vault SecretRef ids use this convention: + +```text +/ +``` + +Examples: + +| SecretRef id | Default KV v2 Vault read | Returned field | +| ----------------------------- | ---------------------------------- | -------------- | +| `providers/openrouter/apiKey` | `secret/data/providers/openrouter` | `apiKey` | +| `providers/openai/apiKey` | `secret/data/providers/openai` | `apiKey` | +| `teams/agent-prod/openrouter` | `secret/data/teams/agent-prod` | `openrouter` | + +The returned Vault field must be a string. + +For KV v1, set: + +```bash +export OPENCLAW_VAULT_KV_VERSION=1 +``` + +Then `providers/openrouter/apiKey` reads: + +```text +secret/providers/openrouter -> apiKey +``` + +## What OpenClaw stores + +Applying a Vault setup plan stores a plugin-managed provider: + +```json +{ + "source": "exec", + "pluginIntegration": { + "pluginId": "vault", + "integrationId": "vault" + } +} +``` + +Credential fields point at that provider: + +```json +{ "source": "exec", "provider": "vault", "id": "providers/openrouter/apiKey" } +``` + +The resolved value lives only in the active runtime secrets snapshot. + +## Containers and managed deployments + +Containerized Gateways still use the same plugin and SecretRef config. The +container must receive: + +- `VAULT_ADDR` +- one auth source: + - `VAULT_TOKEN` + - `OPENCLAW_VAULT_AUTH_METHOD=token_file` plus `VAULT_TOKEN_FILE` + - `OPENCLAW_VAULT_AUTH_METHOD=jwt` plus `OPENCLAW_VAULT_AUTH_MOUNT`, + `OPENCLAW_VAULT_AUTH_ROLE`, and `OPENCLAW_VAULT_JWT_FILE` + - `OPENCLAW_VAULT_AUTH_METHOD=kubernetes` plus `OPENCLAW_VAULT_AUTH_ROLE`; optionally + override `OPENCLAW_VAULT_AUTH_MOUNT` or `OPENCLAW_VAULT_JWT_FILE` +- optional `VAULT_NAMESPACE`, `OPENCLAW_VAULT_KV_MOUNT`, and + `OPENCLAW_VAULT_KV_VERSION` + +When using Kubernetes, prefer `OPENCLAW_VAULT_AUTH_METHOD=kubernetes` +when Vault has Kubernetes auth configured for the cluster. Use +`OPENCLAW_VAULT_AUTH_METHOD=jwt` only when Vault is configured to treat the cluster +as a generic JWT/OIDC issuer. Either option is better than a long-lived Vault +token in a Kubernetes Secret. Vault Agent sidecar or injector deployments can +use `token_file` instead. + +For multi-tenant Vault setups, keep tenant routing in Vault policy and +deployment config. OpenClaw does not require a fixed mount, role, or path: each +Gateway environment can set its own `OPENCLAW_VAULT_KV_MOUNT`, +`OPENCLAW_VAULT_AUTH_ROLE`, and SecretRef ids. If one shared Gateway must resolve +different Vault users at the same time, use manually configured exec providers +that wrap distinct auth environments, or split tenants across Gateway +environments with separate Vault env. + +## Related + +- [Secrets management](/gateway/secrets) +- [`openclaw secrets`](/cli/secrets) +- [Plugin inventory](/plugins/plugin-inventory) diff --git a/extensions/vault/index.ts b/extensions/vault/index.ts new file mode 100644 index 00000000000..2323da225c9 --- /dev/null +++ b/extensions/vault/index.ts @@ -0,0 +1,24 @@ +import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; + +export default definePluginEntry({ + id: "vault", + name: "Vault", + description: "HashiCorp Vault SecretRef provider integration.", + register(api: OpenClawPluginApi) { + api.registerCli( + async ({ program, config }) => { + const { registerVaultCommands } = await import("./src/cli.js"); + registerVaultCommands({ program, config }); + }, + { + descriptors: [ + { + name: "vault", + description: "Manage the Vault SecretRef provider integration", + hasSubcommands: true, + }, + ], + }, + ); + }, +}); diff --git a/extensions/vault/npm-shrinkwrap.json b/extensions/vault/npm-shrinkwrap.json new file mode 100644 index 00000000000..0ca13c30be4 --- /dev/null +++ b/extensions/vault/npm-shrinkwrap.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/vault", + "version": "2026.6.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/vault", + "version": "2026.6.2" + } + } +} diff --git a/extensions/vault/openclaw.plugin.json b/extensions/vault/openclaw.plugin.json new file mode 100644 index 00000000000..9b55d5b998c --- /dev/null +++ b/extensions/vault/openclaw.plugin.json @@ -0,0 +1,47 @@ +{ + "id": "vault", + "name": "Vault", + "description": "HashiCorp Vault SecretRef provider integration.", + "activation": { + "onStartup": false, + "onCommands": ["vault"] + }, + "commandAliases": [ + { + "name": "vault", + "kind": "cli" + } + ], + "secretProviderIntegrations": { + "vault": { + "providerAlias": "vault", + "displayName": "HashiCorp Vault", + "description": "Resolve OpenClaw exec SecretRefs from HashiCorp Vault KV secrets.", + "source": "exec", + "command": "${node}", + "args": ["./vault-secret-ref-resolver.js"], + "timeoutMs": 15000, + "noOutputTimeoutMs": 15000, + "maxOutputBytes": 1048576, + "passEnv": [ + "VAULT_ADDR", + "VAULT_TOKEN", + "VAULT_TOKEN_FILE", + "VAULT_NAMESPACE", + "OPENCLAW_VAULT_AUTH_METHOD", + "OPENCLAW_VAULT_AUTH_MOUNT", + "OPENCLAW_VAULT_AUTH_ROLE", + "OPENCLAW_VAULT_JWT_FILE", + "OPENCLAW_VAULT_KV_MOUNT", + "OPENCLAW_VAULT_KV_VERSION", + "NODE_EXTRA_CA_CERTS", + "NODE_USE_SYSTEM_CA" + ] + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/vault/package.json b/extensions/vault/package.json new file mode 100644 index 00000000000..65589834e18 --- /dev/null +++ b/extensions/vault/package.json @@ -0,0 +1,27 @@ +{ + "name": "@openclaw/vault", + "version": "2026.6.2", + "private": true, + "description": "HashiCorp Vault SecretRef provider integration for OpenClaw", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "build": { + "staticAssets": [ + { + "source": "./vault-secret-ref-resolver.js", + "output": "vault-secret-ref-resolver.js" + }, + { + "source": "./vault-secret-id.js", + "output": "vault-secret-id.js" + } + ] + } + } +} diff --git a/extensions/vault/src/cli.test.ts b/extensions/vault/src/cli.test.ts new file mode 100644 index 00000000000..e06d3569f56 --- /dev/null +++ b/extensions/vault/src/cli.test.ts @@ -0,0 +1,243 @@ +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { testing } from "./cli.js"; + +describe("vault CLI setup plan", () => { + it("generates plugin-managed provider config and model api key targets", () => { + const providerConfig = testing.buildProviderConfig(); + const providerSecrets = testing.collectProviderSecrets({ + openaiId: "providers/openai/apiKey", + anthropicId: "providers/anthropic/apiKey", + providerKey: ["local-openai=providers/local-openai/apiKey"], + }); + const plan = testing.buildPlan({ + providerAlias: "vault", + providerConfig, + providerSecrets, + }); + + expect(plan.providerUpserts).toEqual({ + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }); + expect(plan.targets).toEqual([ + { + type: "models.providers.apiKey", + path: "models.providers.openai.apiKey", + pathSegments: ["models", "providers", "openai", "apiKey"], + providerId: "openai", + ref: { + source: "exec", + provider: "vault", + id: "providers/openai/apiKey", + }, + }, + { + type: "models.providers.apiKey", + path: "models.providers.anthropic.apiKey", + pathSegments: ["models", "providers", "anthropic", "apiKey"], + providerId: "anthropic", + ref: { + source: "exec", + provider: "vault", + id: "providers/anthropic/apiKey", + }, + }, + { + type: "models.providers.apiKey", + path: "models.providers.local-openai.apiKey", + pathSegments: ["models", "providers", "local-openai", "apiKey"], + providerId: "local-openai", + ref: { + source: "exec", + provider: "vault", + id: "providers/local-openai/apiKey", + }, + }, + ]); + }); + + it("generates targets for arbitrary known openclaw secret targets", () => { + const plan = testing.buildPlan({ + providerAlias: "vault", + providerConfig: testing.buildProviderConfig(), + providerSecrets: [], + configTargetSecrets: testing.parseConfigTargetMappings([ + "channels.telegram.botToken=channels/telegram/botToken", + "models.providers.openai.headers.x-api-key=providers/openai/proxyKey", + "auth-profiles:main:profiles.openai:default.key=providers/openai/apiKey", + ]), + }); + + expect(plan.targets).toEqual([ + { + type: "channels.telegram.botToken", + path: "channels.telegram.botToken", + pathSegments: ["channels", "telegram", "botToken"], + ref: { + source: "exec", + provider: "vault", + id: "channels/telegram/botToken", + }, + }, + { + type: "models.providers.headers", + path: "models.providers.openai.headers.x-api-key", + pathSegments: ["models", "providers", "openai", "headers", "x-api-key"], + providerId: "openai", + ref: { + source: "exec", + provider: "vault", + id: "providers/openai/proxyKey", + }, + }, + { + type: "auth-profiles.api_key.key", + path: "profiles.openai:default.key", + pathSegments: ["profiles", "openai:default", "key"], + agentId: "main", + ref: { + source: "exec", + provider: "vault", + id: "providers/openai/apiKey", + }, + }, + ]); + }); + + it("parses config target mappings", () => { + expect( + testing.parseConfigTargetMappings([ + "channels.telegram.botToken=channels/telegram/botToken", + "auth-profiles:main:profiles.openai:default.key=providers/openai/apiKey", + ]), + ).toEqual([ + { + path: "channels.telegram.botToken", + secretId: "channels/telegram/botToken", + }, + { + path: "profiles.openai:default.key", + agentId: "main", + secretId: "providers/openai/apiKey", + }, + ]); + }); + + it("rejects duplicate model provider targets", () => { + expect(() => + testing.collectProviderSecrets({ + openaiId: "providers/openai/apiKey", + providerKey: ["OpenAI=providers/openai/other"], + }), + ).toThrow("Duplicate model provider id in Vault setup: OpenAI"); + }); + + it("rejects traversal segments in Vault secret ids", () => { + expect(() => testing.parseProviderKeyMappings(["openai=providers/../openai/apiKey"])).toThrow( + "Invalid --provider-key openai Vault secret id", + ); + }); + + it.each([ + "providers/openai/apiKey/", + "/providers/openai/apiKey", + "providers//openai/apiKey", + "apiKey", + ])("rejects non-canonical Vault secret id %s", (secretId) => { + expect(() => testing.parseProviderKeyMappings([`openai=${secretId}`])).toThrow( + "Invalid --provider-key openai Vault secret id", + ); + }); + + it("rejects unsupported config target paths", () => { + expect(() => + testing.createConfigSecretTarget({ + providerAlias: "vault", + path: "secrets.github_pat", + secretId: "github/pat", + }), + ).toThrow("Unknown or unsupported Vault setup target path: secrets.github_pat"); + }); + + it("rejects duplicate config target paths", () => { + expect(() => + testing.buildPlan({ + providerAlias: "vault", + providerConfig: testing.buildProviderConfig(), + providerSecrets: [ + { + providerId: "openai", + secretId: "providers/openai/apiKey", + }, + ], + configTargetSecrets: [ + { + path: "models.providers.openai.apiKey", + secretId: "providers/openai/other", + }, + ], + }), + ).toThrow("Duplicate secret target path in Vault setup: models.providers.openai.apiKey"); + }); + + it("discovers a configured custom Vault provider alias for status", () => { + expect( + testing.resolveStatusProviderAlias({ + secrets: { + providers: { + "corp-vault": { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + }, + }), + ).toBe("corp-vault"); + }); + + it("requires an explicit status alias when multiple Vault providers are configured", () => { + const config = { + secrets: { + providers: { + "corp-vault": { + source: "exec" as const, + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + "prod-vault": { + source: "exec" as const, + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + }, + }; + + expect(() => testing.resolveStatusProviderAlias(config)).toThrow( + "Multiple Vault provider aliases are configured (corp-vault, prod-vault)", + ); + expect(testing.resolveStatusProviderAlias(config, "prod-vault")).toBe("prod-vault"); + }); + + it("reports the packaged resolver path when the CLI is bundled", async () => { + const baseUrl = pathToFileURL("/app/dist/index.js").href; + const [, bundledPath] = testing.resolverScriptPathCandidates(baseUrl); + + await expect( + testing.resolveResolverScriptPath(baseUrl, async (filePath) => filePath === bundledPath), + ).resolves.toBe(bundledPath); + }); +}); diff --git a/extensions/vault/src/cli.ts b/extensions/vault/src/cli.ts new file mode 100644 index 00000000000..7e4acc4a2fa --- /dev/null +++ b/extensions/vault/src/cli.ts @@ -0,0 +1,581 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { fileURLToPath } from "node:url"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; +import { resolveSecretPlanTargetByPath } from "openclaw/plugin-sdk/secret-ref-runtime"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { parseVaultSecretId } from "../vault-secret-id.js"; + +type CommandLike = { + command(name: string): CommandLike; + description(value: string): CommandLike; + option( + flags: string, + description: string, + defaultValueOrParser?: string | ((value: string, previous?: string[]) => string[]), + defaultValue?: string[], + ): CommandLike; + action(fn: (options: TOptions) => void | Promise): CommandLike; +}; + +type SecretRef = { + source: "exec"; + provider: string; + id: string; +}; + +type SecretsPlanTarget = { + type: string; + path: string; + pathSegments: string[]; + agentId?: string; + providerId?: string; + accountId?: string; + ref: SecretRef; +}; + +type VaultExecProviderConfig = { + source: "exec"; + pluginIntegration: { + pluginId: "vault"; + integrationId: "vault"; + }; +}; + +type ProviderSecretMapping = { + providerId: string; + secretId: string; +}; + +type ConfigTargetSecretMapping = { + path: string; + agentId?: string; + secretId: string; +}; + +type SecretsApplyPlan = { + version: 1; + protocolVersion: 1; + generatedAt: string; + generatedBy: "manual"; + providerUpserts: Record; + targets: SecretsPlanTarget[]; +}; + +type RegisterVaultCommandsParams = { + program: CommandLike; + config: OpenClawConfig; +}; + +type StatusOptions = { + json?: boolean; + providerAlias?: string; +}; + +type SetupOptions = { + planOut?: string; + providerAlias?: string; + openaiId?: string; + anthropicId?: string; + openrouterId?: string; + providerKey?: string[]; + target?: string[]; +}; + +type ProviderStatus = { + configured: boolean; + source?: string; + command?: string; + pluginIntegration?: { + pluginId: string; + integrationId: string; + }; +}; + +const VAULT_PROVIDER_ALIAS = "vault"; +const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; +const MODEL_PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); + +function writeLine(message = ""): void { + process.stdout.write(`${message}\n`); +} + +function writeJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function parseDotPath(pathname: string): string[] { + return pathname + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +function toDotPath(segments: string[]): string { + return segments.join("."); +} + +function assertValidProviderAlias(value: string): void { + if (!SECRET_PROVIDER_ALIAS_PATTERN.test(value)) { + throw new Error( + `Invalid provider alias "${value}". Use lowercase letters, numbers, underscores, or hyphens.`, + ); + } +} + +function assertValidModelProviderId(label: string, value: string): void { + if (!MODEL_PROVIDER_ID_PATTERN.test(value)) { + throw new Error(`Invalid ${label} model provider id: ${value}`); + } +} + +function assertValidVaultSecretId(label: string, value: string): void { + try { + parseVaultSecretId(value); + } catch { + throw new Error(`Invalid ${label} Vault secret id: ${value}`); + } +} + +function readProviderStatus(config: OpenClawConfig, providerAlias: string): ProviderStatus { + const provider = config.secrets?.providers?.[providerAlias]; + if (!isRecord(provider)) { + return { configured: false }; + } + const base = { + configured: true, + source: normalizeOptionalString(provider.source), + }; + if (provider.source !== "exec") { + return base; + } + if ("pluginIntegration" in provider) { + return { + ...base, + pluginIntegration: provider.pluginIntegration, + }; + } + return { + ...base, + command: normalizeOptionalString(provider.command), + }; +} + +function isVaultIntegrationProvider(value: unknown): boolean { + if (!isRecord(value) || value.source !== "exec" || !isRecord(value.pluginIntegration)) { + return false; + } + return ( + value.pluginIntegration.pluginId === "vault" && + value.pluginIntegration.integrationId === "vault" + ); +} + +function resolveStatusProviderAlias(config: OpenClawConfig, requestedAlias?: string): string { + const explicitAlias = normalizeOptionalString(requestedAlias); + if (explicitAlias) { + assertValidProviderAlias(explicitAlias); + return explicitAlias; + } + if (readProviderStatus(config, VAULT_PROVIDER_ALIAS).configured) { + return VAULT_PROVIDER_ALIAS; + } + const configuredAliases = Object.entries(config.secrets?.providers ?? {}) + .filter(([, provider]) => isVaultIntegrationProvider(provider)) + .map(([alias]) => alias) + .toSorted(); + if (configuredAliases.length > 1) { + throw new Error( + `Multiple Vault provider aliases are configured (${configuredAliases.join(", ")}). Use --provider-alias .`, + ); + } + return configuredAliases[0] ?? VAULT_PROVIDER_ALIAS; +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function resolverScriptPathCandidates(baseUrl: string): string[] { + return [ + fileURLToPath(new URL("../vault-secret-ref-resolver.js", baseUrl)), + fileURLToPath(new URL("./extensions/vault/vault-secret-ref-resolver.js", baseUrl)), + ]; +} + +async function resolveResolverScriptPath( + baseUrl = import.meta.url, + exists: (filePath: string) => Promise = pathExists, +): Promise { + const candidates = resolverScriptPathCandidates(baseUrl); + for (const candidate of candidates) { + if (await exists(candidate)) { + return candidate; + } + } + return candidates[0]; +} + +function buildProviderConfig(): VaultExecProviderConfig { + return { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }; +} + +function createModelApiKeyTarget(params: { + providerAlias: string; + providerId: string; + secretId: string; +}): SecretsPlanTarget { + assertValidModelProviderId("target", params.providerId); + return { + type: "models.providers.apiKey", + path: `models.providers.${params.providerId}.apiKey`, + pathSegments: ["models", "providers", params.providerId, "apiKey"], + providerId: params.providerId, + ref: { + source: "exec", + provider: params.providerAlias, + id: params.secretId, + }, + }; +} + +function parseTargetSpecifier(value: string): { + path: string; + agentId?: string; +} { + if (value.startsWith("auth-profiles:")) { + const remainder = value.slice("auth-profiles:".length); + const separatorIndex = remainder.indexOf(":"); + const agentId = separatorIndex >= 0 ? remainder.slice(0, separatorIndex) : ""; + const targetPath = separatorIndex >= 0 ? remainder.slice(separatorIndex + 1) : ""; + if (!agentId || !targetPath) { + throw new Error(`Invalid --target auth-profiles target: ${value}`); + } + return { agentId, path: targetPath }; + } + return { + path: value.startsWith("openclaw:") ? value.slice("openclaw:".length) : value, + }; +} + +function createConfigSecretTarget(params: { + providerAlias: string; + path: string; + agentId?: string; + secretId: string; +}): SecretsPlanTarget { + const pathSegments = parseDotPath(params.path); + const normalizedPath = toDotPath(pathSegments); + if ( + pathSegments.length === 0 || + normalizedPath !== params.path || + pathSegments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment)) + ) { + throw new Error(`Invalid --target config path: ${params.path}`); + } + const resolved = resolveSecretPlanTargetByPath({ + configFile: params.agentId ? "auth-profiles.json" : "openclaw.json", + pathSegments, + }); + if (!resolved) { + throw new Error(`Unknown or unsupported Vault setup target path: ${params.path}`); + } + return { + type: resolved.targetType, + path: normalizedPath, + pathSegments, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(resolved.providerId ? { providerId: resolved.providerId } : {}), + ...(resolved.accountId ? { accountId: resolved.accountId } : {}), + ref: { + source: "exec", + provider: params.providerAlias, + id: params.secretId, + }, + }; +} + +function parseProviderKeyMappings(values: string[] | undefined): ProviderSecretMapping[] { + return (values ?? []).map((value) => { + const separator = value.indexOf("="); + if (separator <= 0 || separator === value.length - 1) { + throw new Error( + `Invalid --provider-key value "${value}". Use =.`, + ); + } + const providerId = value.slice(0, separator).trim(); + const secretId = value.slice(separator + 1).trim(); + assertValidModelProviderId("--provider-key", providerId); + assertValidVaultSecretId(`--provider-key ${providerId}`, secretId); + return { providerId, secretId }; + }); +} + +function parseConfigTargetMappings(values: string[] | undefined): ConfigTargetSecretMapping[] { + return (values ?? []).map((value) => { + const separator = value.indexOf("="); + if (separator <= 0 || separator === value.length - 1) { + throw new Error( + `Invalid --target value "${value}". Use =.`, + ); + } + const target = parseTargetSpecifier(value.slice(0, separator).trim()); + const secretId = value.slice(separator + 1).trim(); + assertValidVaultSecretId(`--target ${target.path}`, secretId); + return Object.assign( + { path: target.path, secretId }, + target.agentId ? { agentId: target.agentId } : {}, + ); + }); +} + +function collectProviderSecrets(options: { + openaiId?: string; + anthropicId?: string; + openrouterId?: string; + providerKey?: string[]; +}): ProviderSecretMapping[] { + const providerSecrets: ProviderSecretMapping[] = []; + if (options.openaiId) { + providerSecrets.push({ providerId: "openai", secretId: options.openaiId }); + } + if (options.anthropicId) { + providerSecrets.push({ providerId: "anthropic", secretId: options.anthropicId }); + } + if (options.openrouterId) { + providerSecrets.push({ providerId: "openrouter", secretId: options.openrouterId }); + } + providerSecrets.push(...parseProviderKeyMappings(options.providerKey)); + + const seen = new Set(); + for (const entry of providerSecrets) { + const normalized = entry.providerId.toLowerCase(); + if (seen.has(normalized)) { + throw new Error(`Duplicate model provider id in Vault setup: ${entry.providerId}`); + } + seen.add(normalized); + } + return providerSecrets; +} + +function assertNoDuplicatePlanTargets(targets: SecretsPlanTarget[]): void { + const seen = new Set(); + for (const target of targets) { + const key = target.agentId + ? `auth-profiles:${target.agentId}:${target.path}` + : `openclaw:${target.path}`; + if (seen.has(key)) { + throw new Error(`Duplicate secret target path in Vault setup: ${target.path}`); + } + seen.add(key); + } +} + +function buildPlan(params: { + providerAlias: string; + providerConfig: VaultExecProviderConfig; + providerSecrets: ProviderSecretMapping[]; + configTargetSecrets?: ConfigTargetSecretMapping[]; +}): SecretsApplyPlan { + const targets = [ + ...params.providerSecrets.map((entry) => + createModelApiKeyTarget({ + providerAlias: params.providerAlias, + providerId: entry.providerId, + secretId: entry.secretId, + }), + ), + ...(params.configTargetSecrets ?? []).map((entry) => + createConfigSecretTarget({ + providerAlias: params.providerAlias, + path: entry.path, + ...(entry.agentId ? { agentId: entry.agentId } : {}), + secretId: entry.secretId, + }), + ), + ]; + assertNoDuplicatePlanTargets(targets); + return { + version: 1, + protocolVersion: 1, + generatedAt: new Date().toISOString(), + generatedBy: "manual", + providerUpserts: { + [params.providerAlias]: params.providerConfig, + }, + targets, + }; +} + +async function promptOptionalSecretId(label: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return undefined; + } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return normalizeOptionalString(await rl.question(`${label} Vault secret id (blank to skip): `)); + } finally { + rl.close(); + } +} + +async function promptProviderSecrets(options: SetupOptions): Promise { + const openaiId = + normalizeOptionalString(options.openaiId) ?? (await promptOptionalSecretId("OpenAI")); + const anthropicId = + normalizeOptionalString(options.anthropicId) ?? (await promptOptionalSecretId("Anthropic")); + const openrouterId = + normalizeOptionalString(options.openrouterId) ?? (await promptOptionalSecretId("OpenRouter")); + if (openaiId) { + assertValidVaultSecretId("OpenAI", openaiId); + } + if (anthropicId) { + assertValidVaultSecretId("Anthropic", anthropicId); + } + if (openrouterId) { + assertValidVaultSecretId("OpenRouter", openrouterId); + } + return collectProviderSecrets({ + ...(openaiId ? { openaiId } : {}), + ...(anthropicId ? { anthropicId } : {}), + ...(openrouterId ? { openrouterId } : {}), + providerKey: options.providerKey, + }); +} + +async function runStatus(config: OpenClawConfig, options: StatusOptions): Promise { + const providerAlias = resolveStatusProviderAlias(config, options.providerAlias); + const provider = readProviderStatus(config, providerAlias); + const authMethod = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token"; + const result = { + providerAlias, + provider, + resolverScript: await resolveResolverScriptPath(), + vaultAddr: normalizeOptionalString(process.env.VAULT_ADDR), + authMethod, + authMount: + normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_MOUNT) ?? + (authMethod === "kubernetes" ? "kubernetes" : "jwt"), + authRole: normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE), + hasJwtFile: Boolean(normalizeOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE)), + hasVaultTokenFile: Boolean(normalizeOptionalString(process.env.VAULT_TOKEN_FILE)), + kvMount: normalizeOptionalString(process.env.OPENCLAW_VAULT_KV_MOUNT) ?? "secret", + kvVersion: normalizeOptionalString(process.env.OPENCLAW_VAULT_KV_VERSION) ?? "2", + hasVaultToken: Boolean(normalizeOptionalString(process.env.VAULT_TOKEN)), + }; + if (options.json) { + writeJson(result); + return; + } + writeLine(`Vault provider: ${provider.configured ? "configured" : "not configured"}`); + if (provider.source) { + writeLine(`Source: ${provider.source}`); + } + if (provider.command) { + writeLine(`Command: ${provider.command}`); + } + if (provider.pluginIntegration) { + writeLine( + `Plugin integration: ${provider.pluginIntegration.pluginId}:${provider.pluginIntegration.integrationId}`, + ); + } + writeLine(`Resolver: ${result.resolverScript}`); + writeLine(`VAULT_ADDR: ${result.vaultAddr ?? "not set"}`); + writeLine(`Auth method: ${result.authMethod}`); + writeLine(`VAULT_TOKEN: ${result.hasVaultToken ? "set" : "not set"}`); + writeLine(`VAULT_TOKEN_FILE: ${result.hasVaultTokenFile ? "set" : "not set"}`); + writeLine(`Auth mount: ${result.authMount}`); + writeLine(`Auth role: ${result.authRole ?? "not set"}`); + writeLine(`OPENCLAW_VAULT_JWT_FILE: ${result.hasJwtFile ? "set" : "not set"}`); + writeLine(`KV mount: ${result.kvMount}`); + writeLine(`KV version: ${result.kvVersion}`); +} + +async function runSetup(options: SetupOptions): Promise { + const providerAlias = normalizeOptionalString(options.providerAlias) ?? VAULT_PROVIDER_ALIAS; + assertValidProviderAlias(providerAlias); + const providerSecrets = await promptProviderSecrets(options); + const plan = buildPlan({ + providerAlias, + providerConfig: buildProviderConfig(), + providerSecrets, + configTargetSecrets: parseConfigTargetMappings(options.target), + }); + const planPath = + normalizeOptionalString(options.planOut) ?? + path.join(resolvePreferredOpenClawTmpDir(), `openclaw-vault-secrets-${process.pid}.json`); + await fs.writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8"); + writeLine(`Plan written to ${planPath}`); + writeLine(`Targets: ${plan.targets.length}`); + writeLine(""); + writeLine("Next steps:"); + writeLine(` openclaw secrets apply --from ${planPath} --dry-run --allow-exec`); + writeLine(` openclaw secrets apply --from ${planPath} --allow-exec`); + writeLine(" openclaw secrets audit --check --allow-exec"); + writeLine(" openclaw secrets reload"); +} + +export function registerVaultCommands(params: RegisterVaultCommandsParams): void { + const vault = params.program.command("vault").description("Manage Vault SecretRefs"); + vault + .command("status") + .description("Show Vault SecretRef provider status") + .option("--json", "Print JSON status") + .option("--provider-alias ", "Secret provider alias to inspect") + .action((options: StatusOptions) => runStatus(params.config, options)); + vault + .command("setup") + .description("Create a Vault SecretRef setup plan") + .option("--plan-out ", "Write the generated secrets apply plan to a path") + .option("--provider-alias ", "Secret provider alias to configure", VAULT_PROVIDER_ALIAS) + .option("--openai-id ", "Vault secret id for models.providers.openai.apiKey") + .option("--anthropic-id ", "Vault secret id for models.providers.anthropic.apiKey") + .option("--openrouter-id ", "Vault secret id for models.providers.openrouter.apiKey") + .option( + "--provider-key ", + "Vault secret id for any models.providers..apiKey target", + (value: string, previous: string[] = []) => [...previous, value], + [], + ) + .option( + "--target ", + "Vault secret id for any known SecretRef target path", + (value: string, previous: string[] = []) => [...previous, value], + [], + ) + .action((options: SetupOptions) => runSetup(options)); +} + +export const testing = { + buildPlan, + buildProviderConfig, + collectProviderSecrets, + createConfigSecretTarget, + createModelApiKeyTarget, + parseConfigTargetMappings, + parseProviderKeyMappings, + resolveStatusProviderAlias, + resolveResolverScriptPath, + resolverScriptPathCandidates, +}; diff --git a/extensions/vault/src/secret-ref-resolver.test.ts b/extensions/vault/src/secret-ref-resolver.test.ts new file mode 100644 index 00000000000..290596b7f0a --- /dev/null +++ b/extensions/vault/src/secret-ref-resolver.test.ts @@ -0,0 +1,625 @@ +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const resolverPath = fileURLToPath(new URL("../vault-secret-ref-resolver.js", import.meta.url)); +const secretIdHelperPath = fileURLToPath(new URL("../vault-secret-id.js", import.meta.url)); +const manifestPath = fileURLToPath(new URL("../openclaw.plugin.json", import.meta.url)); +const packagePath = fileURLToPath(new URL("../package.json", import.meta.url)); + +function runResolver(params: { + request: unknown; + env?: Record; +}): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [resolverPath], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + VAULT_ADDR: "", + VAULT_TOKEN: "", + VAULT_TOKEN_FILE: "", + VAULT_NAMESPACE: "", + OPENCLAW_VAULT_AUTH_METHOD: "", + OPENCLAW_VAULT_AUTH_MOUNT: "", + OPENCLAW_VAULT_AUTH_ROLE: "", + OPENCLAW_VAULT_JWT_FILE: "", + OPENCLAW_VAULT_KV_MOUNT: "", + OPENCLAW_VAULT_KV_VERSION: "", + ...params.env, + }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", reject); + child.on("exit", (code) => { + resolve({ stdout, stderr, code }); + }); + child.stdin.end(`${JSON.stringify(params.request)}\n`); + }); +} + +const servers: Array<{ close: () => Promise }> = []; +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); +}); + +async function writeTempFile(name: string, value: string): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "openclaw-vault-test-")); + tempDirs.push(dir); + const filePath = path.join(dir, name); + await writeFile(filePath, value, "utf8"); + return filePath; +} + +async function startVaultFixture() { + const requests: Array<{ url?: string; token?: string; namespace?: string }> = []; + const server = createServer((request, response) => { + requests.push({ + url: request.url, + token: request.headers["x-vault-token"]?.toString(), + namespace: request.headers["x-vault-namespace"]?.toString(), + }); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + data: { + data: { + apiKey: "not-a-real-vault-value", + }, + }, + }), + ); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("fixture server did not bind to a TCP port"); + } + return { + requests, + vaultAddr: `http://127.0.0.1:${address.port}`, + }; +} + +async function startVaultErrorFixture() { + const server = createServer((_request, response) => { + response.statusCode = 403; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ errors: ["token not-a-real-sensitive-value denied"] })); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("fixture server did not bind to a TCP port"); + } + return { + vaultAddr: `http://127.0.0.1:${address.port}`, + }; +} + +async function readRequestBody(request: import("node:http").IncomingMessage): Promise { + return await new Promise((resolve, reject) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += String(chunk); + }); + request.on("error", reject); + request.on("end", () => resolve(body)); + }); +} + +async function startVaultJwtFixture() { + const requests: Array<{ + url?: string; + method?: string; + token?: string; + namespace?: string; + body?: unknown; + }> = []; + const server = createServer((request, response) => { + void (async () => { + const body = await readRequestBody(request); + requests.push({ + url: request.url, + method: request.method, + token: request.headers["x-vault-token"]?.toString(), + namespace: request.headers["x-vault-namespace"]?.toString(), + body: body ? JSON.parse(body) : undefined, + }); + response.setHeader("content-type", "application/json"); + if ( + request.url === "/v1/auth/keycloak/login" || + request.url === "/v1/auth/kubernetes/login" + ) { + response.end( + JSON.stringify({ + auth: { + client_token: "not-a-real-vault-client-token", + }, + }), + ); + return; + } + response.end( + JSON.stringify({ + data: { + data: { + apiKey: "not-a-real-vault-value", + }, + }, + }), + ); + })().catch((error: unknown) => { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("fixture server did not bind to a TCP port"); + } + return { + requests, + vaultAddr: `http://127.0.0.1:${address.port}`, + }; +} + +async function startVaultJwtErrorFixture() { + const server = createServer((request, response) => { + void readRequestBody(request) + .then(() => { + response.statusCode = 403; + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify({ errors: ["jwt not-a-real-sensitive-jwt denied"] })); + }) + .catch((error: unknown) => { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + }); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + servers.push({ + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("fixture server did not bind to a TCP port"); + } + return { + vaultAddr: `http://127.0.0.1:${address.port}`, + }; +} + +describe("plugin manifest", () => { + it("declares the Vault resolver as a managed Node SecretRef preset", () => { + const resolverSource = readFileSync(resolverPath, "utf8"); + const childTimeoutMatch = /const VAULT_FETCH_TIMEOUT_MS = (\d+);/u.exec(resolverSource); + const childTimeoutMs = Number(childTimeoutMatch?.[1]); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + secretProviderIntegrations?: Record>; + }; + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as { + openclaw?: { + build?: { + staticAssets?: Array<{ source?: string; output?: string }>; + }; + }; + }; + + expect(manifest.secretProviderIntegrations?.vault).toMatchObject({ + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./vault-secret-ref-resolver.js"], + passEnv: expect.arrayContaining([ + "VAULT_ADDR", + "VAULT_TOKEN", + "VAULT_TOKEN_FILE", + "OPENCLAW_VAULT_AUTH_METHOD", + "OPENCLAW_VAULT_AUTH_MOUNT", + "OPENCLAW_VAULT_AUTH_ROLE", + "OPENCLAW_VAULT_JWT_FILE", + "NODE_EXTRA_CA_CERTS", + "NODE_USE_SYSTEM_CA", + ]), + }); + expect(childTimeoutMs).toBeGreaterThan(0); + expect(manifest.secretProviderIntegrations?.vault?.timeoutMs).toBeGreaterThan( + childTimeoutMs * 2, + ); + expect(manifest.secretProviderIntegrations?.vault?.noOutputTimeoutMs).toBeGreaterThan( + childTimeoutMs * 2, + ); + expect(manifest.secretProviderIntegrations?.vault?.passEnv).not.toContain( + "OPENCLAW_VAULT_VALUES_JSON", + ); + expect(manifest.secretProviderIntegrations?.vault?.allowInsecurePath).toBeUndefined(); + expect(resolverSource).toContain("#!/usr/bin/env node"); + const pluginSdkRootImport = ["openclaw", "plugin-sdk"].join("/"); + expect(resolverSource).not.toContain(pluginSdkRootImport); + expect(packageJson.openclaw?.build?.staticAssets).toContainEqual({ + source: "./vault-secret-ref-resolver.js", + output: "vault-secret-ref-resolver.js", + }); + expect(packageJson.openclaw?.build?.staticAssets).toContainEqual({ + source: "./vault-secret-id.js", + output: "vault-secret-id.js", + }); + expect(readFileSync(secretIdHelperPath, "utf8")).toContain("parseVaultSecretId"); + }); +}); + +describe("vault SecretRef resolver", () => { + it("requires Vault auth instead of accepting plaintext inline values", async () => { + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: "https://vault.example.test", + OPENCLAW_VAULT_VALUES_JSON: JSON.stringify({ + "providers/openai/apiKey": "not-a-real-value", + }), + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: {}, + errors: { + "providers/openai/apiKey": { + message: "VAULT_TOKEN is required.", + }, + }, + }); + }); + + it("reads KV v2 secrets from Vault using path and field ids", async () => { + const fixture = await startVaultFixture(); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + VAULT_TOKEN: "not-a-real-auth-header", + VAULT_NAMESPACE: "team-a", + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: { + "providers/openai/apiKey": "not-a-real-vault-value", + }, + errors: {}, + }); + expect(fixture.requests).toEqual([ + { + url: "/v1/secret/data/providers/openai", + token: "not-a-real-auth-header", + namespace: "team-a", + }, + ]); + }); + + it("rejects dot segments before building Vault request URLs", async () => { + const fixture = await startVaultFixture(); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/../../../sys/mounts/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + VAULT_TOKEN: "not-a-real-auth-header", + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: {}, + errors: { + "providers/../../../sys/mounts/apiKey": { + message: + 'Vault SecretRef id "providers/../../../sys/mounts/apiKey" must not contain dot path segments.', + }, + }, + }); + expect(fixture.requests).toEqual([]); + }); + + it.each(["/providers/openai/apiKey", "providers/openai/apiKey/", "providers//openai/apiKey"])( + "rejects empty path segments in Vault id %s", + async (id) => { + const fixture = await startVaultFixture(); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: [id], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + VAULT_TOKEN: "not-a-real-auth-header", + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: {}, + errors: { + [id]: { + message: `Vault SecretRef id "${id}" must not contain empty path segments.`, + }, + }, + }); + expect(fixture.requests).toEqual([]); + }, + ); + + it("reads the Vault client token from a token file", async () => { + const fixture = await startVaultFixture(); + const tokenFile = await writeTempFile("vault-token", "not-a-real-file-token\n"); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + VAULT_TOKEN_FILE: tokenFile, + OPENCLAW_VAULT_AUTH_METHOD: "token_file", + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: { + "providers/openai/apiKey": "not-a-real-vault-value", + }, + errors: {}, + }); + expect(fixture.requests).toEqual([ + { + url: "/v1/secret/data/providers/openai", + token: "not-a-real-file-token", + namespace: undefined, + }, + ]); + }); + + it("exchanges a workload JWT for a Vault token before reading KV secrets", async () => { + const fixture = await startVaultJwtFixture(); + const jwtFile = await writeTempFile("vault-jwt", "not-a-real-workload-jwt\n"); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + VAULT_NAMESPACE: "team-a", + OPENCLAW_VAULT_AUTH_METHOD: "jwt", + OPENCLAW_VAULT_AUTH_MOUNT: "keycloak", + OPENCLAW_VAULT_AUTH_ROLE: "openclaw", + OPENCLAW_VAULT_JWT_FILE: jwtFile, + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: { + "providers/openai/apiKey": "not-a-real-vault-value", + }, + errors: {}, + }); + expect(fixture.requests).toEqual([ + { + url: "/v1/auth/keycloak/login", + method: "POST", + token: undefined, + namespace: "team-a", + body: { + role: "openclaw", + jwt: "not-a-real-workload-jwt", + }, + }, + { + url: "/v1/secret/data/providers/openai", + method: "GET", + token: "not-a-real-vault-client-token", + namespace: "team-a", + body: undefined, + }, + ]); + }); + + it("uses Vault kubernetes auth defaults with a service account JWT file", async () => { + const fixture = await startVaultJwtFixture(); + const jwtFile = await writeTempFile("kubernetes-service-account-token", "not-a-real-k8s-jwt\n"); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + OPENCLAW_VAULT_AUTH_METHOD: "kubernetes", + OPENCLAW_VAULT_AUTH_ROLE: "openclaw", + OPENCLAW_VAULT_JWT_FILE: jwtFile, + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: { + "providers/openai/apiKey": "not-a-real-vault-value", + }, + errors: {}, + }); + expect(fixture.requests).toEqual([ + { + url: "/v1/auth/kubernetes/login", + method: "POST", + token: undefined, + namespace: undefined, + body: { + role: "openclaw", + jwt: "not-a-real-k8s-jwt", + }, + }, + { + url: "/v1/secret/data/providers/openai", + method: "GET", + token: "not-a-real-vault-client-token", + namespace: undefined, + body: undefined, + }, + ]); + }); + + it("returns per-id errors when Vault auth is not configured", async () => { + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/anthropic/apiKey"], + }, + env: { + VAULT_ADDR: "https://vault.example.test", + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: {}, + errors: { + "providers/anthropic/apiKey": { + message: "VAULT_TOKEN is required.", + }, + }, + }); + }); + + it("does not echo Vault response bodies in resolver errors", async () => { + const fixture = await startVaultErrorFixture(); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + VAULT_TOKEN: "not-a-real-auth-header", + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: {}, + errors: { + "providers/openai/apiKey": { + message: 'Vault read failed for "providers/openai/apiKey" (403).', + }, + }, + }); + expect(result.stdout).not.toContain("not-a-real-sensitive-value"); + }); + + it("does not echo Vault jwt login response bodies in resolver errors", async () => { + const fixture = await startVaultJwtErrorFixture(); + const jwtFile = await writeTempFile("vault-jwt", "not-a-real-sensitive-jwt\n"); + const result = await runResolver({ + request: { + protocolVersion: 1, + provider: "vault", + ids: ["providers/openai/apiKey"], + }, + env: { + VAULT_ADDR: fixture.vaultAddr, + OPENCLAW_VAULT_AUTH_METHOD: "jwt", + OPENCLAW_VAULT_AUTH_ROLE: "openclaw", + OPENCLAW_VAULT_JWT_FILE: jwtFile, + }, + }); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(JSON.parse(result.stdout)).toEqual({ + protocolVersion: 1, + values: {}, + errors: { + "providers/openai/apiKey": { + message: "Vault jwt login failed (403).", + }, + }, + }); + expect(result.stdout).not.toContain("not-a-real-sensitive-jwt"); + }); +}); diff --git a/extensions/vault/vault-secret-id.d.ts b/extensions/vault/vault-secret-id.d.ts new file mode 100644 index 00000000000..f19c57c783c --- /dev/null +++ b/extensions/vault/vault-secret-id.d.ts @@ -0,0 +1,6 @@ +export type ParsedVaultSecretId = { + secretPath: string; + field: string; +}; + +export function parseVaultSecretId(id: string): ParsedVaultSecretId; diff --git a/extensions/vault/vault-secret-id.js b/extensions/vault/vault-secret-id.js new file mode 100644 index 00000000000..1f581bba82f --- /dev/null +++ b/extensions/vault/vault-secret-id.js @@ -0,0 +1,23 @@ +const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/; + +export function parseVaultSecretId(id) { + const parts = id.split("/"); + if (parts.some((part) => part.length === 0)) { + throw new Error(`Vault SecretRef id "${id}" must not contain empty path segments.`); + } + if (!EXEC_SECRET_REF_ID_PATTERN.test(id)) { + throw new Error(`Vault SecretRef id "${id}" contains unsupported characters.`); + } + if (parts.length < 2) { + throw new Error( + `Vault SecretRef id "${id}" must use "/", for example "providers/openai/apiKey".`, + ); + } + if (parts.some((part) => part === "." || part === "..")) { + throw new Error(`Vault SecretRef id "${id}" must not contain dot path segments.`); + } + return { + secretPath: parts.slice(0, -1).join("/"), + field: parts.at(-1), + }; +} diff --git a/extensions/vault/vault-secret-ref-resolver.js b/extensions/vault/vault-secret-ref-resolver.js new file mode 100644 index 00000000000..7659ee1ca17 --- /dev/null +++ b/extensions/vault/vault-secret-ref-resolver.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { parseVaultSecretId } from "./vault-secret-id.js"; + +const KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"; +const VAULT_FETCH_TIMEOUT_MS = 5000; + +function readStdin() { + return new Promise((resolve, reject) => { + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + input += String(chunk); + }); + process.stdin.on("error", reject); + process.stdin.on("end", () => resolve(input)); + }); +} + +function writeResponse(response) { + process.stdout.write(`${JSON.stringify(response)}\n`); +} + +function parseRequest(input) { + const parsed = JSON.parse(input); + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ids)) { + throw new Error("invalid exec SecretRef request"); + } + return { + protocolVersion: 1, + ids: parsed.ids.filter((id) => typeof id === "string" && id.length > 0), + }; +} + +function normalizeVaultAddress() { + const raw = process.env.VAULT_ADDR?.trim(); + if (!raw) { + throw new Error("VAULT_ADDR is required."); + } + const address = raw.replace(/\/+$/u, ""); + let parsed; + try { + parsed = new URL(address); + } catch { + throw new Error("VAULT_ADDR must be a valid http or https URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("VAULT_ADDR must be a valid http or https URL."); + } + return address; +} + +function normalizeOptionalString(value) { + return value?.trim() || undefined; +} + +function resolveVaultAuthMethod() { + const method = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token"; + if ( + method === "token" || + method === "token_file" || + method === "jwt" || + method === "kubernetes" + ) { + return method; + } + throw new Error("OPENCLAW_VAULT_AUTH_METHOD must be token, token_file, jwt, or kubernetes."); +} + +function resolveVaultTokenEnv() { + const token = process.env.VAULT_TOKEN?.trim(); + if (!token) { + throw new Error("VAULT_TOKEN is required."); + } + return token; +} + +async function resolveVaultTokenFile() { + const tokenFile = normalizeOptionalString(process.env.VAULT_TOKEN_FILE); + if (!tokenFile) { + throw new Error("VAULT_TOKEN_FILE is required."); + } + const token = (await readFile(tokenFile, "utf8")).trim(); + if (!token) { + throw new Error("VAULT_TOKEN_FILE did not contain a token."); + } + return token; +} + +function resolveKvMount() { + return process.env.OPENCLAW_VAULT_KV_MOUNT?.trim().replace(/^\/+|\/+$/gu, "") || "secret"; +} + +function resolveKvVersion() { + const raw = process.env.OPENCLAW_VAULT_KV_VERSION?.trim(); + if (!raw || raw === "2") { + return 2; + } + if (raw === "1") { + return 1; + } + throw new Error("OPENCLAW_VAULT_KV_VERSION must be 1 or 2."); +} + +function encodePath(pathValue) { + return pathValue + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function buildVaultUrl(baseUrl, params) { + const mount = encodePath(resolveKvMount()); + const secretPath = encodePath(params.secretPath); + if (resolveKvVersion() === 2) { + return `${baseUrl}/v1/${mount}/data/${secretPath}`; + } + return `${baseUrl}/v1/${mount}/${secretPath}`; +} + +function assertVaultRequestUrl(baseUrl, requestUrl) { + const base = new URL(baseUrl); + const target = new URL(requestUrl); + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Error("Vault request URL must be a valid http or https URL."); + } + if (target.origin !== base.origin) { + throw new Error("Vault request URL must stay on the configured VAULT_ADDR origin."); + } +} + +async function fetchVault(baseUrl, url, init) { + assertVaultRequestUrl(baseUrl, url); + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), VAULT_FETCH_TIMEOUT_MS); + try { + return await fetch(url, { + ...init, + redirect: "manual", + signal: abortController.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +function addVaultNamespaceHeader(headers) { + const namespace = process.env.VAULT_NAMESPACE?.trim(); + if (namespace) { + headers["X-Vault-Namespace"] = namespace; + } +} + +function resolveVaultAuthMount(method) { + return process.env.OPENCLAW_VAULT_AUTH_MOUNT?.trim().replace(/^\/+|\/+$/gu, "") || method; +} + +function resolveVaultAuthRole(method) { + const role = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE); + if (!role) { + throw new Error(`OPENCLAW_VAULT_AUTH_ROLE is required for ${method} auth.`); + } + return role; +} + +async function resolveVaultJwt(method) { + const jwtFile = + normalizeOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE) ?? + (method === "kubernetes" ? KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH : undefined); + if (!jwtFile) { + throw new Error("OPENCLAW_VAULT_JWT_FILE is required for jwt auth."); + } + const jwt = (await readFile(jwtFile, "utf8")).trim(); + if (!jwt) { + throw new Error("OPENCLAW_VAULT_JWT_FILE did not contain a JWT."); + } + return jwt; +} + +function readVaultLoginToken(payload, method) { + const token = payload?.auth?.client_token; + if (typeof token !== "string" || !token.trim()) { + throw new Error(`Vault ${method} login response did not include auth.client_token.`); + } + return token; +} + +async function resolveVaultTokenFromJwt(baseUrl, method) { + const mount = encodePath(resolveVaultAuthMount(method)); + const headers = { + "Content-Type": "application/json", + }; + addVaultNamespaceHeader(headers); + const response = await fetchVault(baseUrl, `${baseUrl}/v1/auth/${mount}/login`, { + method: "POST", + headers, + body: JSON.stringify({ + role: resolveVaultAuthRole(method), + jwt: await resolveVaultJwt(method), + }), + }); + if (!response.ok) { + throw new Error(`Vault ${method} login failed (${response.status}).`); + } + return readVaultLoginToken(await response.json(), method); +} + +async function resolveVaultClientToken(baseUrl) { + switch (resolveVaultAuthMethod()) { + case "token": + return resolveVaultTokenEnv(); + case "token_file": + return await resolveVaultTokenFile(); + case "jwt": + return await resolveVaultTokenFromJwt(baseUrl, "jwt"); + case "kubernetes": + return await resolveVaultTokenFromJwt(baseUrl, "kubernetes"); + } + throw new Error("Unsupported Vault auth method."); +} + +function readStringField(payload, parsedId) { + const record = payload; + const data = resolveKvVersion() === 2 ? record?.data?.data : record?.data; + const value = data?.[parsedId.field]; + if (typeof value !== "string") { + throw new Error( + `Vault secret "${parsedId.secretPath}/${parsedId.field}" did not contain a string field "${parsedId.field}".`, + ); + } + return value; +} + +async function readVaultSecret(baseUrl, vaultToken, id) { + const parsedId = parseVaultSecretId(id); + const headers = { + "X-Vault-Token": vaultToken, + }; + addVaultNamespaceHeader(headers); + const response = await fetchVault(baseUrl, buildVaultUrl(baseUrl, parsedId), { headers }); + if (!response.ok) { + throw new Error(`Vault read failed for "${id}" (${response.status}).`); + } + return readStringField(await response.json(), parsedId); +} + +async function resolveFromVault(ids) { + const response = { protocolVersion: 1, values: {}, errors: {} }; + if (ids.length === 0) { + return response; + } + const contextPromise = Promise.resolve().then(async () => { + const baseUrl = normalizeVaultAddress(); + return { + baseUrl, + vaultToken: await resolveVaultClientToken(baseUrl), + }; + }); + await Promise.all( + ids.map(async (id) => { + try { + const { baseUrl, vaultToken } = await contextPromise; + response.values[id] = await readVaultSecret(baseUrl, vaultToken, id); + } catch (error) { + response.errors[id] = { + message: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + return response; +} + +async function main() { + const input = await readStdin(); + const request = parseRequest(input); + writeResponse(await resolveFromVault(request.ids)); +} + +/** @param {unknown} error */ +function handleFatalError(error) { + writeResponse({ + protocolVersion: 1, + values: {}, + errors: { + request: { + message: error instanceof Error ? error.message : String(error), + }, + }, + }); +} + +main().catch(handleFatalError); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee137ad2531..92809442cd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1677,6 +1677,12 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/vault: + devDependencies: + '@openclaw/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + extensions/venice: devDependencies: '@openclaw/plugin-sdk': diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 9650a82739d..985c89653a8 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10465, + 10467, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5222, + 5223, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/plugin-sdk/secret-ref-runtime.ts b/src/plugin-sdk/secret-ref-runtime.ts index c0428c29c91..039284fa511 100644 --- a/src/plugin-sdk/secret-ref-runtime.ts +++ b/src/plugin-sdk/secret-ref-runtime.ts @@ -1,6 +1,29 @@ // Narrow shared secret-ref helpers for plugin config and secret-contract paths. +import { resolveSecretPlanTargetByPath as resolveSecretPlanTargetByPathInternal } from "../secrets/target-registry-query.js"; + export { coerceSecretRef } from "../config/types.secrets.js"; export type { SecretInput, SecretRef } from "../config/types.secrets.js"; export { resolveSecretRefValues } from "../secrets/resolve.js"; export { applyResolvedAssignments, createResolverContext } from "../secrets/runtime-shared.js"; + +export type ResolvedSecretPlanTarget = { + targetType: string; + providerId?: string; + accountId?: string; +}; + +export function resolveSecretPlanTargetByPath(params: { + configFile: "openclaw.json" | "auth-profiles.json"; + pathSegments: string[]; +}): ResolvedSecretPlanTarget | null { + const resolved = resolveSecretPlanTargetByPathInternal(params); + if (!resolved) { + return null; + } + return { + targetType: resolved.entry.targetType, + ...(resolved.providerId ? { providerId: resolved.providerId } : {}), + ...(resolved.accountId ? { accountId: resolved.accountId } : {}), + }; +} diff --git a/src/secrets/apply.test.ts b/src/secrets/apply.test.ts index 30d3282fcd1..b0883144116 100644 --- a/src/secrets/apply.test.ts +++ b/src/secrets/apply.test.ts @@ -1085,4 +1085,263 @@ describe("secrets apply", () => { mode: "json", }); }); + + it("enables plugin owners for plugin-managed exec provider upserts", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + entries: { + vault: { + hooks: { + allowConversationAccess: false, + }, + }, + }, + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + const nextConfig = await applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }); + expect(nextConfig.plugins?.entries?.vault).toEqual({ + hooks: { + allowConversationAccess: false, + }, + enabled: true, + }); + }); + + it("does not re-enable explicitly disabled plugin owners for plugin-managed exec provider upserts", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + entries: { + vault: { + enabled: false, + }, + }, + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + await expect( + applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }), + ).rejects.toThrow( + 'Cannot apply plugin-managed SecretRef provider "vault" because plugins.entries.vault.enabled is false.', + ); + }); + + it("rejects plugin-managed exec provider upserts when plugins are globally disabled", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + enabled: false, + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + await expect( + applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }), + ).rejects.toThrow( + 'Cannot apply plugin-managed SecretRef provider "vault" because plugins.enabled is false.', + ); + }); + + it("rejects plugin-managed exec provider upserts for denied plugin owners", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + deny: ["vault"], + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + await expect( + applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }), + ).rejects.toThrow( + 'Cannot apply plugin-managed SecretRef provider "vault" because plugins.deny includes "vault".', + ); + }); + + it("rejects plugin-managed exec provider upserts for normalized denied plugin owners", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + deny: ["Vault"], + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + await expect( + applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }), + ).rejects.toThrow( + 'Cannot apply plugin-managed SecretRef provider "vault" because plugins.deny includes "vault".', + ); + }); + + it("does not re-enable normalized disabled plugin owners for plugin-managed exec provider upserts", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + entries: { + Vault: { + enabled: false, + }, + }, + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + await expect( + applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }), + ).rejects.toThrow( + 'Cannot apply plugin-managed SecretRef provider "vault" because plugins.entries.vault.enabled is false.', + ); + }); + + it("does not widen restrictive plugin allowlists for plugin-managed exec provider upserts", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + allow: ["openai"], + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + await expect( + applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + }), + ).rejects.toThrow( + 'Cannot apply plugin-managed SecretRef provider "vault" because plugins.allow does not include "vault". Add the plugin to plugins.allow before applying this plan.', + ); + }); + + it("preserves normalized restrictive plugin allowlist entries for plugin-managed exec provider upserts", async () => { + await writeJsonFile(fixture.configPath, { + plugins: { + allow: ["Vault"], + }, + }); + + const plan = createPlan({ + providerUpserts: { + vault: { + source: "exec", + pluginIntegration: { + pluginId: "vault", + integrationId: "vault", + }, + }, + }, + targets: [], + }); + + const nextConfig = (await applyTesting.projectConfigForTest({ + plan, + env: fixture.env, + })) as { + plugins?: { + allow?: string[]; + entries?: Record; + }; + }; + expect(nextConfig.plugins?.allow).toEqual(["Vault"]); + expect(nextConfig.plugins?.entries?.vault).toEqual({ enabled: true }); + }); }); diff --git a/src/secrets/apply.ts b/src/secrets/apply.ts index 3cc3972813c..2af6b46605c 100644 --- a/src/secrets/apply.ts +++ b/src/secrets/apply.ts @@ -25,6 +25,7 @@ import { } from "../config/config.js"; import type { ConfigWriteOptions } from "../config/io.js"; import { coerceSecretRef, type SecretProviderConfig } from "../config/types.secrets.js"; +import { normalizePluginConfigId } from "../plugins/plugin-config-trust.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js"; @@ -122,6 +123,23 @@ function planContainsExecReferences(plan: SecretsApplyPlan): boolean { return Object.values(plan.providerUpserts ?? {}).some((provider) => provider.source === "exec"); } +function hasPluginPolicyId(list: unknown, pluginId: string): boolean { + return Array.isArray(list) && list.some((entry) => normalizePluginConfigId(entry) === pluginId); +} + +function findPluginEntry(entries: unknown, pluginId: string): Record | undefined { + if (!isRecord(entries)) { + return undefined; + } + for (const [key, value] of Object.entries(entries)) { + if (normalizePluginConfigId(key) !== pluginId) { + continue; + } + return isRecord(value) ? value : {}; + } + return undefined; +} + function resolveTarget( target: SecretsPlanTarget, ): NonNullable> { @@ -202,6 +220,50 @@ function applyProviderPlanMutations(params: { changed = true; } + for (const providerConfig of Object.values(params.upserts ?? {})) { + if (providerConfig.source !== "exec" || !("pluginIntegration" in providerConfig)) { + continue; + } + // Plugin-managed exec providers fail closed unless the owner is active. + // A secrets plan that upserts one must also make that owner resolvable. + const pluginId = normalizePluginConfigId(providerConfig.pluginIntegration.pluginId); + params.config.plugins ??= {}; + if (params.config.plugins.enabled === false) { + throw new Error( + `Cannot apply plugin-managed SecretRef provider "${pluginId}" because plugins.enabled is false. Enable plugins before applying this plan.`, + ); + } + if (hasPluginPolicyId(params.config.plugins.deny, pluginId)) { + throw new Error( + `Cannot apply plugin-managed SecretRef provider "${pluginId}" because plugins.deny includes "${pluginId}". Remove the deny rule before applying this plan.`, + ); + } + const previousEntry = findPluginEntry(params.config.plugins.entries, pluginId); + if (previousEntry?.enabled === false) { + throw new Error( + `Cannot apply plugin-managed SecretRef provider "${pluginId}" because plugins.entries.${pluginId}.enabled is false. Enable the plugin explicitly before applying this plan.`, + ); + } + if ( + Array.isArray(params.config.plugins.allow) && + params.config.plugins.allow.length > 0 && + !hasPluginPolicyId(params.config.plugins.allow, pluginId) + ) { + throw new Error( + `Cannot apply plugin-managed SecretRef provider "${pluginId}" because plugins.allow does not include "${pluginId}". Add the plugin to plugins.allow before applying this plan.`, + ); + } + params.config.plugins.entries ??= {}; + if (previousEntry?.enabled === true) { + continue; + } + params.config.plugins.entries[pluginId] = { + ...(isRecord(previousEntry) ? previousEntry : {}), + enabled: true, + }; + changed = true; + } + if (!changed) { return false; } diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index 7249488fd76..21bad81a78c 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -13,6 +13,7 @@ import { import type { DiscoveredConfigSecretTarget, ResolvedPlanTarget, + SecretTargetConfigFile, SecretTargetRegistryEntry, } from "./target-registry-types.js"; @@ -380,6 +381,32 @@ function resolvePlanTargetAgainstEntries( return null; } +/** + * Resolves a plan-capable secret target by owning config document and concrete path. + */ +export function resolveSecretPlanTargetByPath(params: { + configFile: SecretTargetConfigFile; + pathSegments: string[]; +}): ResolvedPlanTarget | null { + if (params.configFile === "openclaw.json") { + return resolveConfigSecretTargetByPath(params.pathSegments); + } + for (const entry of getCompiledSecretTargetRegistryState().authProfilesCompiledSecretTargets) { + if (!entry.includeInPlan) { + continue; + } + const matched = matchPathTokens(params.pathSegments, entry.pathTokens); + if (!matched) { + continue; + } + const resolved = toResolvedPlanTarget(entry, params.pathSegments, matched.captures); + if (resolved) { + return resolved; + } + } + return null; +} + /** * Resolves an openclaw.json config path to the matching plan-capable secrets target. */ diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index cbcc1e3f855..aec5071da4c 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -10,6 +10,7 @@ import { getCoreSecretTargetRegistry } from "./target-registry-data.js"; import { discoverConfigSecretTargetsByIds, resolveConfigSecretTargetByPath, + resolveSecretPlanTargetByPath, } from "./target-registry.js"; describe("secret target registry", () => { @@ -61,6 +62,21 @@ describe("secret target registry", () => { expect(target).toBeNull(); }); + it("resolves plan targets by owning config document", () => { + const configTarget = resolveSecretPlanTargetByPath({ + configFile: "openclaw.json", + pathSegments: ["models", "providers", "openai", "apiKey"], + }); + const authProfileTarget = resolveSecretPlanTargetByPath({ + configFile: "auth-profiles.json", + pathSegments: ["profiles", "openai:default", "key"], + }); + + expect(configTarget?.entry.targetType).toBe("models.providers.apiKey"); + expect(configTarget?.providerId).toBe("openai"); + expect(authProfileTarget?.entry.targetType).toBe("auth-profiles.api_key.key"); + }); + it("derives bundled web provider api key target paths from plugin manifests", () => { const coreTargetIds = new Set(getCoreSecretTargetRegistry().map((entry) => entry.id)); expect(coreTargetIds.has("plugins.entries.exa.config.webSearch.apiKey")).toBe(false); diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 5f8b41e1b5a..ee327a072c2 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -42,6 +42,8 @@ describe("runtime postbuild static assets", () => { "dist/extensions/acpx/mcp-proxy.mjs", "dist/extensions/diffs-language-pack/assets/viewer-runtime.js", "dist/extensions/diffs/assets/viewer-runtime.js", + "dist/extensions/vault/vault-secret-id.js", + "dist/extensions/vault/vault-secret-ref-resolver.js", ]); }); @@ -62,6 +64,8 @@ describe("runtime postbuild static assets", () => { "dist/extensions/acpx/mcp-proxy.mjs", "dist/extensions/diffs-language-pack/assets/viewer-runtime.js", "dist/extensions/diffs/assets/viewer-runtime.js", + "dist/extensions/vault/vault-secret-id.js", + "dist/extensions/vault/vault-secret-ref-resolver.js", ]); expect(payload.sources).toContain("extensions/diffs-language-pack/assets/viewer-runtime.js"); expect(payload.sources).toContain("extensions/diffs/assets/viewer-runtime.js");