feat(secrets): add Azure Key Vault secret provider#43644
Conversation
Greptile SummaryThis PR introduces
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 490-494
Comment:
**Azure `cacheTtlSeconds` is silently ignored**
The `cacheTtlMs` variable is derived exclusively from the GCP provider's configuration. If a user configures only the Azure provider with a custom `cacheTtlSeconds`, that value is never read and the default 5-minute TTL is used instead.
```ts
// Current — only GCP's TTL is respected
const cacheTtlMs =
secretsConfig?.providers?.gcp?.cacheTtlSeconds != null
? secretsConfig.providers.gcp.cacheTtlSeconds * 1000
: defaultTtlMs;
```
`AzureSecretProvider` exposes a `cacheTtlMillis` getter precisely so callers can read the per-provider TTL. The simplest fix is to resolve each secret using its own provider's TTL inside `resolveAny` / `fetchWithCache`, or at minimum also check the azure config here:
```ts
const gcpTtl = secretsConfig?.providers?.gcp?.cacheTtlSeconds;
const azureTtl = secretsConfig?.providers?.azure?.cacheTtlSeconds;
const cacheTtlMs =
(gcpTtl ?? azureTtl ?? null) != null
? (gcpTtl ?? azureTtl)! * 1000
: defaultTtlMs;
```
Ideally, `fetchWithCache` should accept the provider's own TTL so each provider's configured value is used independently.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 333
Comment:
**Azure provider registered with empty `vaultUrl`, masking misconfiguration**
When `vaultUrl` is absent from the config, the `AzureSecretProvider` is constructed with an empty string (`""`). The provider is silently registered and will only fail later at runtime — when a secret is actually fetched — with a potentially cryptic Azure SDK networking error rather than a clear configuration error.
Consider validating the presence of `vaultUrl` before registering the provider:
```ts
if (name === "azure") {
if (!config?.vaultUrl) {
throw new Error(
'Azure secret provider config is missing required field "vaultUrl". ' +
'Set secrets.providers.azure.vaultUrl in openclaw.json.',
);
}
providers.set("azure", new AzureSecretProvider({ ... }));
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: docs/gateway/secrets.md
Line: 492
Comment:
**Documentation marks Azure Key Vault as "Planned" — but this PR ships it**
The newly-added supported providers table shows:
```
| Azure Key Vault | Planned |
```
Since this PR is implementing and shipping `AzureSecretProvider`, the status should be updated to `✅ Supported` to match the GCP row and accurately reflect what this release provides.
```suggestion
| Azure Key Vault | ✅ Supported |
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: docs/gateway/secrets.md
Line: 453-465
Comment:
**Frontmatter-style metadata rendered as body text in the new section**
The block starting immediately after the `## Additional Secrets Providers` heading:
```
summary: "External secrets management: store credentials in GCP Secret Manager..."
read_when:
- You want to stop storing API keys...
title: "Secrets"
```
looks like it was intended as frontmatter or an internal document header but was accidentally placed in the body. Readers will see this raw key-value syntax rendered as plain text, which is confusing. Either move it to proper frontmatter at the top of the file (if the docs platform supports per-section frontmatter) or remove it entirely.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: package.json
Line: 346
Comment:
**`@google-cloud/secret-manager` added as a hard dependency, inconsistent with Azure's optional approach**
The PR description states that Azure SDK packages are *lazy-loaded as optional peer dependencies*. The `GcpSecretProvider` uses the same lazy `import()` pattern (`await import("@google-cloud/secret-manager")`), yet `@google-cloud/secret-manager` is being added to `dependencies` in `package.json`, making it always installed.
This means every consumer of this package pays the install cost for GCP's SDK even if they only use Azure Key Vault (or no secrets provider at all). For consistency with the stated design, consider moving `@google-cloud/secret-manager` to `peerDependenciesMeta` as optional, mirroring how `@azure/keyvault-secrets` and `@azure/identity` are handled.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: de12897 |
| const defaultTtlMs = 300_000; | ||
| const cacheTtlMs = | ||
| secretsConfig?.providers?.gcp?.cacheTtlSeconds != null | ||
| ? secretsConfig.providers.gcp.cacheTtlSeconds * 1000 | ||
| : defaultTtlMs; |
There was a problem hiding this comment.
Azure cacheTtlSeconds is silently ignored
The cacheTtlMs variable is derived exclusively from the GCP provider's configuration. If a user configures only the Azure provider with a custom cacheTtlSeconds, that value is never read and the default 5-minute TTL is used instead.
// Current — only GCP's TTL is respected
const cacheTtlMs =
secretsConfig?.providers?.gcp?.cacheTtlSeconds != null
? secretsConfig.providers.gcp.cacheTtlSeconds * 1000
: defaultTtlMs;AzureSecretProvider exposes a cacheTtlMillis getter precisely so callers can read the per-provider TTL. The simplest fix is to resolve each secret using its own provider's TTL inside resolveAny / fetchWithCache, or at minimum also check the azure config here:
const gcpTtl = secretsConfig?.providers?.gcp?.cacheTtlSeconds;
const azureTtl = secretsConfig?.providers?.azure?.cacheTtlSeconds;
const cacheTtlMs =
(gcpTtl ?? azureTtl ?? null) != null
? (gcpTtl ?? azureTtl)! * 1000
: defaultTtlMs;Ideally, fetchWithCache should accept the provider's own TTL so each provider's configured value is used independently.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 490-494
Comment:
**Azure `cacheTtlSeconds` is silently ignored**
The `cacheTtlMs` variable is derived exclusively from the GCP provider's configuration. If a user configures only the Azure provider with a custom `cacheTtlSeconds`, that value is never read and the default 5-minute TTL is used instead.
```ts
// Current — only GCP's TTL is respected
const cacheTtlMs =
secretsConfig?.providers?.gcp?.cacheTtlSeconds != null
? secretsConfig.providers.gcp.cacheTtlSeconds * 1000
: defaultTtlMs;
```
`AzureSecretProvider` exposes a `cacheTtlMillis` getter precisely so callers can read the per-provider TTL. The simplest fix is to resolve each secret using its own provider's TTL inside `resolveAny` / `fetchWithCache`, or at minimum also check the azure config here:
```ts
const gcpTtl = secretsConfig?.providers?.gcp?.cacheTtlSeconds;
const azureTtl = secretsConfig?.providers?.azure?.cacheTtlSeconds;
const cacheTtlMs =
(gcpTtl ?? azureTtl ?? null) != null
? (gcpTtl ?? azureTtl)! * 1000
: defaultTtlMs;
```
Ideally, `fetchWithCache` should accept the provider's own TTL so each provider's configured value is used independently.
How can I resolve this? If you propose a fix, please make it concise.| if (name === "azure") { | ||
| providers.set( | ||
| "azure", | ||
| new AzureSecretProvider({ |
There was a problem hiding this comment.
Azure provider registered with empty vaultUrl, masking misconfiguration
When vaultUrl is absent from the config, the AzureSecretProvider is constructed with an empty string (""). The provider is silently registered and will only fail later at runtime — when a secret is actually fetched — with a potentially cryptic Azure SDK networking error rather than a clear configuration error.
Consider validating the presence of vaultUrl before registering the provider:
if (name === "azure") {
if (!config?.vaultUrl) {
throw new Error(
'Azure secret provider config is missing required field "vaultUrl". ' +
'Set secrets.providers.azure.vaultUrl in openclaw.json.',
);
}
providers.set("azure", new AzureSecretProvider({ ... }));
}Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 333
Comment:
**Azure provider registered with empty `vaultUrl`, masking misconfiguration**
When `vaultUrl` is absent from the config, the `AzureSecretProvider` is constructed with an empty string (`""`). The provider is silently registered and will only fail later at runtime — when a secret is actually fetched — with a potentially cryptic Azure SDK networking error rather than a clear configuration error.
Consider validating the presence of `vaultUrl` before registering the provider:
```ts
if (name === "azure") {
if (!config?.vaultUrl) {
throw new Error(
'Azure secret provider config is missing required field "vaultUrl". ' +
'Set secrets.providers.azure.vaultUrl in openclaw.json.',
);
}
providers.set("azure", new AzureSecretProvider({ ... }));
}
```
How can I resolve this? If you propose a fix, please make it concise.| | HashiCorp Vault | Planned | | ||
| | Azure Key Vault | Planned | | ||
|
|
||
| ## Quick Start |
There was a problem hiding this comment.
Documentation marks Azure Key Vault as "Planned" — but this PR ships it
The newly-added supported providers table shows:
| Azure Key Vault | Planned |
Since this PR is implementing and shipping AzureSecretProvider, the status should be updated to ✅ Supported to match the GCP row and accurately reflect what this release provides.
| ## Quick Start | |
| | Azure Key Vault | ✅ Supported | |
Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/gateway/secrets.md
Line: 492
Comment:
**Documentation marks Azure Key Vault as "Planned" — but this PR ships it**
The newly-added supported providers table shows:
```
| Azure Key Vault | Planned |
```
Since this PR is implementing and shipping `AzureSecretProvider`, the status should be updated to `✅ Supported` to match the GCP row and accurately reflect what this release provides.
```suggestion
| Azure Key Vault | ✅ Supported |
```
How can I resolve this? If you propose a fix, please make it concise.| - Security posture: [Security](/gateway/security) | ||
| - Environment precedence: [Environment Variables](/help/environment) | ||
|
|
||
| --- | ||
|
|
||
| ## Additional Secrets Providers | ||
|
|
||
| summary: "External secrets management: store credentials in GCP Secret Manager instead of plaintext files" | ||
| read_when: | ||
|
|
||
| - You want to stop storing API keys in plaintext config files | ||
| - You want per-agent secret isolation in multi-agent setups | ||
| - You want to set up GCP Secret Manager for OpenClaw |
There was a problem hiding this comment.
Frontmatter-style metadata rendered as body text in the new section
The block starting immediately after the ## Additional Secrets Providers heading:
summary: "External secrets management: store credentials in GCP Secret Manager..."
read_when:
- You want to stop storing API keys...
title: "Secrets"
looks like it was intended as frontmatter or an internal document header but was accidentally placed in the body. Readers will see this raw key-value syntax rendered as plain text, which is confusing. Either move it to proper frontmatter at the top of the file (if the docs platform supports per-section frontmatter) or remove it entirely.
Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/gateway/secrets.md
Line: 453-465
Comment:
**Frontmatter-style metadata rendered as body text in the new section**
The block starting immediately after the `## Additional Secrets Providers` heading:
```
summary: "External secrets management: store credentials in GCP Secret Manager..."
read_when:
- You want to stop storing API keys...
title: "Secrets"
```
looks like it was intended as frontmatter or an internal document header but was accidentally placed in the body. Readers will see this raw key-value syntax rendered as plain text, which is confusing. Either move it to proper frontmatter at the top of the file (if the docs platform supports per-section frontmatter) or remove it entirely.
How can I resolve this? If you propose a fix, please make it concise.| @@ -343,6 +343,7 @@ | |||
| "@buape/carbon": "0.0.0-beta-20260216184201", | |||
| "@clack/prompts": "^1.1.0", | |||
| "@discordjs/voice": "^0.19.1", | |||
| "@google-cloud/secret-manager": "^6.1.1", | |||
There was a problem hiding this comment.
@google-cloud/secret-manager added as a hard dependency, inconsistent with Azure's optional approach
The PR description states that Azure SDK packages are lazy-loaded as optional peer dependencies. The GcpSecretProvider uses the same lazy import() pattern (await import("@google-cloud/secret-manager")), yet @google-cloud/secret-manager is being added to dependencies in package.json, making it always installed.
This means every consumer of this package pays the install cost for GCP's SDK even if they only use Azure Key Vault (or no secrets provider at all). For consistency with the stated design, consider moving @google-cloud/secret-manager to peerDependenciesMeta as optional, mirroring how @azure/keyvault-secrets and @azure/identity are handled.
Prompt To Fix With AI
This is a comment left during a code review.
Path: package.json
Line: 346
Comment:
**`@google-cloud/secret-manager` added as a hard dependency, inconsistent with Azure's optional approach**
The PR description states that Azure SDK packages are *lazy-loaded as optional peer dependencies*. The `GcpSecretProvider` uses the same lazy `import()` pattern (`await import("@google-cloud/secret-manager")`), yet `@google-cloud/secret-manager` is being added to `dependencies` in `package.json`, making it always installed.
This means every consumer of this package pays the install cost for GCP's SDK even if they only use Azure Key Vault (or no secrets provider at all). For consistency with the stated design, consider moving `@google-cloud/secret-manager` to `peerDependenciesMeta` as optional, mirroring how `@azure/keyvault-secrets` and `@azure/identity` are handled.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de12897b0f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| version: string | undefined, | ||
| cacheTtlMs: number, | ||
| ): Promise<string> { | ||
| const cacheKey = `${provider.name}:${name}#${version ?? "latest"}`; |
There was a problem hiding this comment.
Scope secret cache keys to provider configuration
fetchWithCache uses provider.name plus secret/version as the entire cache key, so two different provider instances of the same type (for example, different Azure vault URLs or GCP projects across config reloads/multi-agent runs) will share cache entries. In that scenario, the second resolution can return a secret from the previous provider context without calling getSecret, which can serve incorrect credentials or leak values across contexts; include provider identity (project/vault/provider alias) in the key or clear cache per config.
Useful? React with 👍 / 👎.
| secretsConfig?.providers?.gcp?.cacheTtlSeconds != null | ||
| ? secretsConfig.providers.gcp.cacheTtlSeconds * 1000 | ||
| : defaultTtlMs; |
There was a problem hiding this comment.
Honor Azure cacheTtlSeconds during secret resolution
resolveConfigSecrets computes the shared cache TTL only from secrets.providers.gcp.cacheTtlSeconds, so Azure's cacheTtlSeconds setting is ignored. In an Azure-only configuration, users cannot lower or raise cache duration as documented, which means secret rotations may not take effect until the hardcoded 5-minute default expires; TTL needs to be selected per referenced provider instead of hardcoding the GCP field.
Useful? React with 👍 / 👎.
de12897 to
609f289
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 609f2893f3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| export async function resolveConfigSecrets( | ||
| obj: unknown, | ||
| secretsConfig: SecretsConfig | undefined, | ||
| providersOverride?: Map<string, SecretProvider>, |
There was a problem hiding this comment.
Wire resolveConfigSecrets into loadConfig
This resolver is introduced but never executed in the config read path: in this commit, loadConfig in src/config/io.ts goes from include/env resolution to validation/return without calling resolveConfigSecrets, so ${gcp:...} and ${azure:...} placeholders remain literal strings at runtime. In practice, users following the new secrets docs will pass unresolved placeholders as credentials and hit downstream auth failures instead of secret lookup.
Useful? React with 👍 / 👎.
|
|
||
| async getSecret(secretName: string, version?: string): Promise<string> { | ||
| const ver = version ?? "latest"; | ||
| const cacheKey = `gcp:${secretName}#${ver}`; |
There was a problem hiding this comment.
Scope GCP provider cache keys by project
GcpSecretProvider.getSecret caches using only gcp:${secretName}#${ver} in the shared secretCache, so project/credential context is not part of the key. If config is reloaded to a different GCP project (or multiple provider instances are used in one process), the method can return a stale secret from the previous project without calling Secret Manager; even fixing fetchWithCache would not address this provider-level collision.
Useful? React with 👍 / 👎.
609f289 to
3cee8ba
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3cee8bac2b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (name === "gcp" && config && config.project) { | ||
| providers.set( | ||
| "gcp", | ||
| new GcpSecretProvider( | ||
| config as { project: string; cacheTtlSeconds?: number; credentialsFile?: string }, |
There was a problem hiding this comment.
Align provider config shape with existing secrets schema
buildSecretProviders assumes entries like secrets.providers.gcp.project/azure.vaultUrl, but config validation still enforces secrets.providers.* as the existing discriminated source union in src/config/zod-schema.core.ts (env|file|exec). In practice, a config that follows this new provider shape is rejected during validation before any resolution can run, so users cannot enable the new provider configuration path at all.
Useful? React with 👍 / 👎.
| const identityMod = await lazyImport<{ | ||
| DefaultAzureCredential: new () => AzureCredential; |
There was a problem hiding this comment.
Declare Azure SDK packages required by dynamic imports
The provider hard-imports @azure/identity (and later @azure/keyvault-secrets) at runtime, but this commit does not add either package to dependencies or peerDependencies in package.json (only @google-cloud/secret-manager is added). On a clean install, ${azure:...} resolution will fail immediately with the install error unless users manually add extra packages, so the Azure provider is not usable out of the box.
Useful? React with 👍 / 👎.
22c8525 to
8d03542
Compare
Adds AzureSecretProvider for resolving secrets from Azure Key Vault.
Uses `@azure/keyvault-secrets` + `@azure/identity` (lazy-loaded optional
peer dependencies — no startup cost unless the provider is used).
Authentication via DefaultAzureCredential chain:
- Environment variables (AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET)
- Managed Identity
- Azure CLI (`az login`)
- Service Principal
Features:
- Version pinning: ${azure:my-secret#version-id}
- Configurable TTL caching (default 5 min)
- Custom credentials file support
Config example:
"secrets": {
"providers": {
"azure": {
"vaultUrl": "https://my-vault.vault.azure.net",
"tenantId": "<tenant-id>"
}
}
}
Usage: ${azure:my-secret-name}
Split from openclaw#24272 per @vincentkoc's request.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d0354240b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| ); | ||
|
|
||
| for (const { full, value: sv } of resolved) { | ||
| working = working.replace(full, sv); |
There was a problem hiding this comment.
Replace refs by position to avoid mutating resolved secret text
The replacement loop uses working = working.replace(full, sv) for each matched placeholder, which always replaces the first matching substring in the current string. If an earlier secret value contains text that looks like another placeholder (for example ${gcp:a} resolves to a string containing ${gcp:b} while ${gcp:b} is also present in the original config), later iterations can rewrite the injected secret value instead of the original placeholder and leave the real placeholder unresolved. This can corrupt resolved credentials/config values in real deployments where secret payloads include template-like text.
Useful? React with 👍 / 👎.
| const value = await provider.getSecret(name, version); | ||
| secretCache.set(cacheKey, { value, expiresAt: Date.now() + cacheTtlMs }); |
There was a problem hiding this comment.
Deduplicate in-flight secret fetches during parallel resolution
resolveAny resolves object/array fields in parallel, but fetchWithCache only caches completed values. When the same reference appears multiple times in one config load, concurrent calls all miss the cache and each executes provider.getSecret(...), causing redundant backend reads (and potential throttling/slow startup) even though a shared cache exists. Add in-flight promise deduplication (or pre-deduplicate references) so identical refs are fetched once per resolution pass.
Useful? React with 👍 / 👎.
8d03542 to
5d90fdc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d90fdc318
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
|
|
||
| // Restore escaped refs | ||
| working = working.replaceAll(ESCAPE_PLACEHOLDER, "${"); |
There was a problem hiding this comment.
Avoid mutating resolved secrets during escape restoration
The escape restoration step uses a fixed sentinel (___OPENCLAW_ESC___) and then blindly runs replaceAll on the fully resolved string, so any resolved secret value containing that substring is rewritten (for example, abc___OPENCLAW_ESC___def becomes abc${def). This can silently corrupt credentials/config values in deployments where secret payloads include that token-like text; use a per-call unique marker or positional reconstruction so only original $${...} escapes are restored.
Useful? React with 👍 / 👎.
|
Thanks for the contribution! After reviewing this PR alongside the related secrets provider PRs (#43640, #43641, #43643, #43644, #43645, #43646), we need to close these for an architectural rework before they can land. The issue: parallel implementation instead of extending the existing systemOpenClaw already has a mature secrets infrastructure in These PRs introduce a new parallel system — a new There's also a hidden dependency across the PR set: the aws, azure, keyring, and vault branches all import What we need insteadNew cloud-backend providers (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault, OS keyring, env vars) should be implemented as resolvers within the existing
We'd love to see these re-submitted with the corrected architecture. |
Summary
Adds
AzureSecretProviderfor resolving secrets from Azure Key Vault.Azure SDK packages (
@azure/keyvault-secrets+@azure/identity) are lazy-loaded as optional peer dependencies — no startup cost unless the provider is configured.Authentication
DefaultAzureCredential chain:
AZURE_TENANT_ID,AZURE_CLIENT_ID,AZURE_CLIENT_SECRET)az login)Features
${azure:my-secret#<version-id>}Config
{ "secrets": { "providers": { "azure": { "vaultUrl": "https://my-vault.vault.azure.net", "tenantId": "<tenant-id>" } } } }Usage:
${azure:my-secret-name}Notes
Split from #24272 per @vincentkoc's request.