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:
Sally O'Malley
2026-07-09 05:30:12 -05:00
committed by GitHub
co-authored by joshavant
parent 6a6aba0d5a
commit e595a8c0ac
30 changed files with 2677 additions and 8 deletions
+6
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -1280,6 +1280,7 @@
"plugins/webhooks",
"plugins/admin-http-rpc",
"plugins/voice-call",
"plugins/vault",
"plugins/memory-wiki",
"plugins/llama-cpp",
"plugins/memory-lancedb",
+24
View File
@@ -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
+1
View File
@@ -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
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -16,7 +16,7 @@ Adds the iMessage channel surface for sending and receiving OpenClaw messages.
## Surface
channels: imessage
channels: imessage; skills
## Related docs
+23
View File
@@ -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)
+1 -1
View File
@@ -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 |
+332
View File
@@ -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)
+24
View File
@@ -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,
},
],
},
);
},
});
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@openclaw/vault",
"version": "2026.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/vault",
"version": "2026.6.2"
}
}
}
+47
View File
@@ -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": {}
}
}
+27
View File
@@ -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"
}
]
}
}
}
+243
View File
@@ -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);
});
});
+581
View File
@@ -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
View File
@@ -0,0 +1,6 @@
export type ParsedVaultSecretId = {
secretPath: string;
field: string;
};
export function parseVaultSecretId(id: string): ParsedVaultSecretId;
+23
View File
@@ -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);
+6
View File
@@ -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':
+2 -2
View File
@@ -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(
+23
View File
@@ -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 } : {}),
};
}
+259
View File
@@ -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 });
});
});
+62
View File
@@ -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;
}
+27
View File
@@ -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.
*/
+16
View File
@@ -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);
+4
View File
@@ -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");