mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
Add Vault SecretRef plugin (#89255)
* Add Vault SecretRef plugin Signed-off-by: sallyom <somalley@redhat.com> * expand Vault setup to registered SecretRef targets Signed-off-by: sallyom <somalley@redhat.com> * 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 <somalley@redhat.com> Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
co-authored by
joshavant
parent
6a6aba0d5a
commit
e595a8c0ac
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1280,6 +1280,7 @@
|
||||
"plugins/webhooks",
|
||||
"plugins/admin-http-rpc",
|
||||
"plugins/voice-call",
|
||||
"plugins/vault",
|
||||
"plugins/memory-wiki",
|
||||
"plugins/llama-cpp",
|
||||
"plugins/memory-lancedb",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,7 +16,7 @@ Adds the iMessage channel surface for sending and receiving OpenClaw messages.
|
||||
|
||||
## Surface
|
||||
|
||||
channels: imessage
|
||||
channels: imessage; skills
|
||||
|
||||
## Related docs
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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=<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=<vault-client-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=<namespace-name>
|
||||
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.
|
||||
|
||||
<Warning>
|
||||
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.
|
||||
</Warning>
|
||||
|
||||
## 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 <provider=id>` writes a SecretRef to
|
||||
`models.providers.<provider>.apiKey`. For custom providers, it does not create
|
||||
the provider's `baseUrl`, `api`, or `models` settings; configure those first.
|
||||
|
||||
Use `--target <path=id>` 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:<agentId>:<path>` 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
|
||||
<vault-secret-path>/<field>
|
||||
```
|
||||
|
||||
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)
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/vault",
|
||||
"version": "2026.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/vault",
|
||||
"version": "2026.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<TOptions>(fn: (options: TOptions) => void | Promise<void>): 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<string, VaultExecProviderConfig>;
|
||||
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<string, unknown> {
|
||||
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 <alias>.`,
|
||||
);
|
||||
}
|
||||
return configuredAliases[0] ?? VAULT_PROVIDER_ALIAS;
|
||||
}
|
||||
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
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<boolean> = pathExists,
|
||||
): Promise<string> {
|
||||
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 <model-provider-id>=<vault-secret-id>.`,
|
||||
);
|
||||
}
|
||||
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 <openclaw-config-path>=<vault-secret-id>.`,
|
||||
);
|
||||
}
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string | undefined> {
|
||||
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<ProviderSecretMapping[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 <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 <path>", "Write the generated secrets apply plan to a path")
|
||||
.option("--provider-alias <alias>", "Secret provider alias to configure", VAULT_PROVIDER_ALIAS)
|
||||
.option("--openai-id <id>", "Vault secret id for models.providers.openai.apiKey")
|
||||
.option("--anthropic-id <id>", "Vault secret id for models.providers.anthropic.apiKey")
|
||||
.option("--openrouter-id <id>", "Vault secret id for models.providers.openrouter.apiKey")
|
||||
.option(
|
||||
"--provider-key <provider=id>",
|
||||
"Vault secret id for any models.providers.<provider>.apiKey target",
|
||||
(value: string, previous: string[] = []) => [...previous, value],
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--target <path=id>",
|
||||
"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,
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
}): 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<void> }> = [];
|
||||
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<string> {
|
||||
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<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
servers.push({
|
||||
close: () =>
|
||||
new Promise<void>((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<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
servers.push({
|
||||
close: () =>
|
||||
new Promise<void>((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<string> {
|
||||
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<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
servers.push({
|
||||
close: () =>
|
||||
new Promise<void>((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<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
servers.push({
|
||||
close: () =>
|
||||
new Promise<void>((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<string, Record<string, unknown>>;
|
||||
};
|
||||
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");
|
||||
});
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export type ParsedVaultSecretId = {
|
||||
secretPath: string;
|
||||
field: string;
|
||||
};
|
||||
|
||||
export function parseVaultSecretId(id: string): ParsedVaultSecretId;
|
||||
@@ -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 "<path>/<field>", 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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
Generated
+6
@@ -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':
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
};
|
||||
expect(nextConfig.plugins?.allow).toEqual(["Vault"]);
|
||||
expect(nextConfig.plugins?.entries?.vault).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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<ReturnType<typeof resolveValidatedPlanTarget>> {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user