Skip to content

feat: add env, keyring, 1Password, AWS, Azure, and Vault secret providers#24272

Closed
akoscz wants to merge 6 commits into
openclaw:mainfrom
akoscz:feature/secrets-extra-providers
Closed

feat: add env, keyring, 1Password, AWS, Azure, and Vault secret providers#24272
akoscz wants to merge 6 commits into
openclaw:mainfrom
akoscz:feature/secrets-extra-providers

Conversation

@akoscz

@akoscz akoscz commented Feb 23, 2026

Copy link
Copy Markdown
Contributor

Closes #17311

Summary

Extends the secrets manager integration with additional provider implementations, building on the GCP Secret Manager foundation established in #16663.

Providers Added

Core (env + keyring)

  • EnvSecretProvider — resolves secrets from environment variables with optional prefix and hyphen-to-underscore mapping
  • KeyringSecretProvider — reads from the OS credential store (macOS Keychain, Linux Secret Service, Windows Credential Manager) via keytar

Cloud

  • AwsSecretProvider — AWS Secrets Manager via @aws-sdk/client-secrets-manager (optional dep, dynamically imported)
  • AzureSecretProvider — Azure Key Vault via @azure/keyvault-secrets (optional dep, dynamically imported)

Self-hosted

  • VaultSecretProvider — HashiCorp Vault KV v2 via HTTP API (no SDK dep)

1Password

  • OnePasswordSecretProvider — 1Password Connect Server via @1password/sdk (optional dep, dynamically imported)

Extras

  • Rotation reminders — labels-based tracking of secret rotation dates, configurable reminder thresholds
  • Auto-rotation — automated gateway token rotation via GCP Secret Manager with verification and rollback
  • openclaw secrets CLIlist, get, set, delete commands for managing secrets across providers
  • Migration scripts (scripts/secrets/) for bootstrapping GCP and migrating existing config values

Tests

245 tests passing across all providers and rotation logic.

Notes

  • All cloud/keyring providers are optional — dynamically imported so missing deps don't break startup
  • Lint clean (0 errors after fixing any types, unused imports, and unbound-method issues from upstream lint rule changes)

Greptile Summary

This PR extends the secrets manager integration with six new provider implementations (env, keyring, 1Password, AWS, Azure, Vault), rotation reminders, auto-rotation for gateway tokens, CLI commands, and migration scripts. The providers are well-structured with proper lazy-loading of optional dependencies, good error handling, and comprehensive test coverage (245 tests).

  • Value corruption risk in resolution: resolveString() in secret-resolution.ts uses String.replace() with the resolved value as the replacement string, which interprets $&, $`, $' as special patterns — values containing dollar signs will be silently corrupted
  • Vault and Azure providers not wired up: VaultSecretProvider and AzureSecretProvider are fully implemented and tested but are not registered in buildSecretProviders(), nor included in the Zod schema or TypeScript types — users cannot configure or use them
  • Wrong event name for expired rotation state: emitRotationEvents() emits the same event name for both the expiring-soon and expired cases, making it impossible for listeners to distinguish between the two states
  • AWS version parameter misuse: getSecret() sets both VersionId (expects UUID) and VersionStage (expects label like AWSCURRENT) to the same value, which will cause API failures for versioned lookups
  • Migration scanner only skips GCP refs: scanForSensitiveValues() only checks for GCP-style references but not refs to other providers, meaning already-migrated values would be flagged again
  • TypeScript type out of sync: types.openclaw.ts only includes gcp in the type definition while the Zod schema validates five providers

Confidence Score: 2/5

  • This PR has multiple functional bugs that would cause silent data corruption and runtime failures — the secret value replacement bug and the unwired providers are particularly impactful.
  • Score of 2 reflects: (1) a data corruption bug in secret resolution that silently mangles values containing $, (2) two fully implemented providers (Vault, Azure) that are dead code from the user's perspective, (3) a wrong event name that breaks monitoring for expired secrets, and (4) an AWS API misuse that will fail for versioned secret lookups. While the overall architecture and test coverage are solid, these bugs need to be fixed before merge.
  • Pay close attention to src/config/secret-resolution.ts (replacement bug + missing providers), src/config/rotation-reminders.ts (wrong event), and src/config/aws-secret-provider.ts (version parameter misuse)

Last reviewed commit: 3e59fc1

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation gateway Gateway runtime scripts Repository scripts commands Command implementations size: XL labels Feb 23, 2026

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

33 files reviewed, 7 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +459 to +461
for (const { full, value: sv } of resolved) {
working = working.replace(full, sv);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secret values containing $ are corrupted during replacement

String.replace(searchValue, replaceValue) interprets special patterns in replaceValue: $& inserts the matched substring, $` and $' insert text before/after the match, and $$ inserts a literal $. If a resolved secret value contains any of these patterns (e.g., a password like pa$$word or a JSON string with $), the replacement will silently produce corrupted output.

Fix by escaping $ in the replacement value:

Suggested change
for (const { full, value: sv } of resolved) {
working = working.replace(full, sv);
}
for (const { full, value: sv } of resolved) {
working = working.replace(full, sv.replaceAll("$", "$$$$"));
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 459-461

Comment:
**Secret values containing `$` are corrupted during replacement**

`String.replace(searchValue, replaceValue)` interprets special patterns in `replaceValue`: `$&` inserts the matched substring, `` $` `` and `$'` insert text before/after the match, and `$$` inserts a literal `$`. If a resolved secret value contains any of these patterns (e.g., a password like `pa$$word` or a JSON string with `$`), the replacement will silently produce corrupted output.

Fix by escaping `$` in the replacement value:

```suggestion
  for (const { full, value: sv } of resolved) {
    working = working.replace(full, sv.replaceAll("$", "$$$$"));
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +129 to +133
const params: Record<string, string> = { SecretId: secretName };
if (version) {
params.VersionId = version;
params.VersionStage = version;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VersionId and VersionStage should not both be set to the same value

In the AWS Secrets Manager API, VersionId is a UUID (e.g., a1b2c3d4-5678-...) and VersionStage is a label (e.g., AWSCURRENT, AWSPREVIOUS). Setting both to the same version string means the API call will fail or behave unexpectedly depending on the format of the version string. AWS docs state: "If you specify both, the API will validate that the VersionStage matches the specified VersionId."

You likely want to infer which parameter to set based on the version format, or just use VersionStage:

Suggested change
const params: Record<string, string> = { SecretId: secretName };
if (version) {
params.VersionId = version;
params.VersionStage = version;
}
const params: Record<string, string> = { SecretId: secretName };
if (version) {
// UUID format → VersionId, otherwise treat as a stage label
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(version);
if (isUuid) {
params.VersionId = version;
} else {
params.VersionStage = version;
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/aws-secret-provider.ts
Line: 129-133

Comment:
**`VersionId` and `VersionStage` should not both be set to the same value**

In the AWS Secrets Manager API, `VersionId` is a UUID (e.g., `a1b2c3d4-5678-...`) and `VersionStage` is a label (e.g., `AWSCURRENT`, `AWSPREVIOUS`). Setting both to the same `version` string means the API call will fail or behave unexpectedly depending on the format of the version string. AWS docs state: "If you specify both, the API will validate that the `VersionStage` matches the specified `VersionId`."

You likely want to infer which parameter to set based on the version format, or just use `VersionStage`:

```suggestion
    const params: Record<string, string> = { SecretId: secretName };
    if (version) {
      // UUID format → VersionId, otherwise treat as a stage label
      const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(version);
      if (isUuid) {
        params.VersionId = version;
      } else {
        params.VersionStage = version;
      }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +311 to +366
export function buildSecretProviders(
secretsConfig: SecretsConfig | undefined,
): Map<string, SecretProvider> {
const providers = new Map<string, SecretProvider>();
if (!secretsConfig?.providers) {
return providers;
}

for (const [name, config] of Object.entries(secretsConfig.providers)) {
if (name === "gcp" && config && config.project) {
providers.set(
"gcp",
new GcpSecretProvider(
config as { project: string; cacheTtlSeconds?: number; credentialsFile?: string },
),
);
}
if (name === "aws" && config && config.region) {
providers.set(
"aws",
new AwsSecretProvider({
region: config.region,
cacheTtlSeconds: config.cacheTtlSeconds,
profile: config.profile,
credentialsFile: config.credentialsFile,
roleArn: config.roleArn,
externalId: config.externalId,
}),
);
}
if (name === "env") {
providers.set("env", new EnvSecretProvider());
}
if (name === "keyring") {
providers.set(
"keyring",
new KeyringSecretProvider({
account: config?.account,
keychainPath: config?.keychainPath,
keychainPassword: config?.keychainPassword,
}),
);
}
if (name === "1password") {
providers.set(
"1password",
new OnePasswordSecretProvider({
vault: config?.vault,
field: config?.field,
}),
);
}
}

return providers;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vault and Azure providers not wired into buildSecretProviders

VaultSecretProvider and AzureSecretProvider are implemented and tested but buildSecretProviders() doesn't import or instantiate them. A user who configures vault or azure in their secrets.providers config will get no provider, and any ${vault:...} or ${azure:...} references will throw UnknownSecretProviderError at runtime.

Similarly, the Zod schema (zod-schema.ts) and TypeScript type (types.openclaw.ts) don't include vault or azure entries, so users can't configure them through the standard config path. If these providers are intended to be functional in this PR, they need to be wired in here (with matching schema/type updates). If they're intentionally deferred, consider noting that in the PR description.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 311-366

Comment:
**Vault and Azure providers not wired into `buildSecretProviders`**

`VaultSecretProvider` and `AzureSecretProvider` are implemented and tested but `buildSecretProviders()` doesn't import or instantiate them. A user who configures `vault` or `azure` in their `secrets.providers` config will get no provider, and any `${vault:...}` or `${azure:...}` references will throw `UnknownSecretProviderError` at runtime.

Similarly, the Zod schema (`zod-schema.ts`) and TypeScript type (`types.openclaw.ts`) don't include `vault` or `azure` entries, so users can't configure them through the standard config path. If these providers are intended to be functional in this PR, they need to be wired in here (with matching schema/type updates). If they're intentionally deferred, consider noting that in the PR description.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +197 to +210
// Secret doesn't exist — create it, then put value
await client.send(
new sdk.CreateSecretCommand({
Name: secretName,
SecretString: value,
}),
);
// CreateSecret already stores the value, but if we want to be explicit:
await client.send(
new sdk.PutSecretValueCommand({
SecretId: secretName,
SecretString: value,
}),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant PutSecretValueCommand after CreateSecretCommand

CreateSecretCommand with SecretString already stores the initial secret value. The follow-up PutSecretValueCommand on lines 205-210 creates an unnecessary second version of the secret. The comment on line 204 acknowledges this. Consider removing the redundant call:

Suggested change
// Secret doesn't exist — create it, then put value
await client.send(
new sdk.CreateSecretCommand({
Name: secretName,
SecretString: value,
}),
);
// CreateSecret already stores the value, but if we want to be explicit:
await client.send(
new sdk.PutSecretValueCommand({
SecretId: secretName,
SecretString: value,
}),
);
// Secret doesn't exist — create it (SecretString stores the initial value)
await client.send(
new sdk.CreateSecretCommand({
Name: secretName,
SecretString: value,
}),
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/aws-secret-provider.ts
Line: 197-210

Comment:
**Redundant `PutSecretValueCommand` after `CreateSecretCommand`**

`CreateSecretCommand` with `SecretString` already stores the initial secret value. The follow-up `PutSecretValueCommand` on lines 205-210 creates an unnecessary second version of the secret. The comment on line 204 acknowledges this. Consider removing the redundant call:

```suggestion
        // Secret doesn't exist — create it (SecretString stores the initial value)
        await client.send(
          new sdk.CreateSecretCommand({
            Name: secretName,
            SecretString: value,
          }),
        );
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/commands/secrets.ts
Comment on lines +106 to +108
if (obj.includes("${gcp:")) {
return results;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scanForSensitiveValues only skips GCP secret refs

The check obj.includes("${gcp:") only skips values already migrated to GCP. Values referencing other providers like ${aws:...}, ${env:...}, ${vault:...}, ${keyring:...}, or ${1password:...} will still be flagged as sensitive values needing migration. This could lead to double-migration attempts.

Suggested change
if (obj.includes("${gcp:")) {
return results;
}
// Skip if already a secret ref
if (/\$\{[a-z]+:/.test(obj)) {
return results;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/secrets.ts
Line: 106-108

Comment:
**`scanForSensitiveValues` only skips GCP secret refs**

The check `obj.includes("${gcp:")` only skips values already migrated to GCP. Values referencing other providers like `${aws:...}`, `${env:...}`, `${vault:...}`, `${keyring:...}`, or `${1password:...}` will still be flagged as sensitive values needing migration. This could lead to double-migration attempts.

```suggestion
    // Skip if already a secret ref
    if (/\$\{[a-z]+:/.test(obj)) {
      return results;
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +511 to +514
const cacheTtlMs =
secretsConfig?.providers?.gcp?.cacheTtlSeconds != null
? secretsConfig.providers.gcp.cacheTtlSeconds * 1000
: defaultTtlMs;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache TTL only reads from GCP provider config

The shared cache TTL is hardcoded to check only secretsConfig.providers.gcp.cacheTtlSeconds. If a user configures only AWS (or another provider) with a custom cacheTtlSeconds, it will be ignored by the shared cache layer. Consider reading the TTL from whichever provider is configured, or taking the max/min across all configured providers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/secret-resolution.ts
Line: 511-514

Comment:
**Cache TTL only reads from GCP provider config**

The shared cache TTL is hardcoded to check only `secretsConfig.providers.gcp.cacheTtlSeconds`. If a user configures only AWS (or another provider) with a custom `cacheTtlSeconds`, it will be ignored by the shared cache layer. Consider reading the TTL from whichever provider is configured, or taking the max/min across all configured providers.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +193 to +197
case "expired":
listener("secret:expiring-soon", r.name, {
daysUntilExpiry: r.status.daysUntilExpiry,
expired: true,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong event name for expired rotation state

The expired branch on line 194 emits the same event name as the expiring-soon branch above it. Listeners cannot distinguish between an almost-expired and a fully-expired rotation. The event name here should be distinct for the expired case.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/rotation-reminders.ts
Line: 193-197

Comment:
**Wrong event name for expired rotation state**

The `expired` branch on line 194 emits the same event name as the `expiring-soon` branch above it. Listeners cannot distinguish between an almost-expired and a fully-expired rotation. The event name here should be distinct for the expired case.

How can I resolve this? If you propose a fix, please make it concise.

@akoscz akoscz force-pushed the feature/secrets-extra-providers branch 5 times, most recently from 9f33cbc to 89edfaa Compare March 4, 2026 02:08
@vincentkoc vincentkoc self-assigned this Mar 4, 2026
@vincentkoc

Copy link
Copy Markdown
Member

Thanks for pushing this.

Since #26155 is now merged, please rebase this branch on latest main and refactor/split the changes into smaller follow-ups (ideally provider-by-provider) so review stays tractable and overlap with merged SecretRef infrastructure is minimized.

If you want, we can help define a clean split plan in-thread.

@akoscz akoscz force-pushed the feature/secrets-extra-providers branch 3 times, most recently from e20c171 to 429627c Compare March 11, 2026 18:28
@akoscz

akoscz commented Mar 12, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of the split PRs (#43640, #43641, #43643, #43644, #43645, #43646). All providers have been implemented in separate, self-contained PRs per @vincentkoc's request.

@akoscz akoscz closed this Mar 12, 2026
akoscz added a commit to akoscz/openclaw that referenced this pull request Mar 13, 2026
Adds AwsSecretProvider for resolving secrets from AWS Secrets Manager.
The AWS SDK (`@aws-sdk/client-secrets-manager`) is lazy-loaded as an
optional peer dependency — no startup cost unless the provider is used.

Authentication follows the standard AWS credential chain:
env vars → shared credentials → IAM role → instance profile.

Features:
- Version pinning: ${aws:my-secret#AWSCURRENT}
- Profile + role ARN + externalId support
- Configurable TTL caching
- Secret rotation detection

Config example:
  "secrets": {
    "providers": {
      "aws": { "region": "us-east-1", "profile": "prod" }
    }
  }

Usage: ${aws:my-secret-name}

Split from openclaw#24272 per @vincentkoc's request.
akoscz added a commit to akoscz/openclaw that referenced this pull request Mar 13, 2026
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.
akoscz added a commit to akoscz/openclaw that referenced this pull request Mar 13, 2026
Adds two complementary systems for managing secret lifecycle:

**RotationReminders** (`rotation-reminders.ts`):
- Track rotation schedules via KV metadata labels in config
- Check if secrets are due for rotation
- Snooze and acknowledge rotation reminders
- CLI: `openclaw secrets check-rotation`

**AutoRotation** (`auto-rotation.ts`):
- Automatically rotate the OpenClaw gateway token in GCP Secret Manager
- Configurable rotation interval (default: 30 days)
- Dry-run mode for testing
- CLI: `openclaw secrets rotate`

**CLI command** (`src/commands/secrets`):
- `openclaw secrets list` — list all configured providers and their secrets
- `openclaw secrets test` — test provider connectivity
- `openclaw secrets rotate` — rotate gateway token
- `openclaw secrets check-rotation` — check rotation status
- `openclaw secrets purge` — remove plaintext secrets from config

Split from openclaw#24272 per @vincentkoc's request.
akoscz added a commit to akoscz/openclaw that referenced this pull request Mar 13, 2026
Adds the EnvSecretProvider which resolves secret references from environment
variables. This is useful for local development and CI pipelines that inject
secrets via env vars.

Also includes:
- Secret reference resolution infrastructure (secret-resolution.ts)
- GCP Secret Manager provider (inline in resolution layer)
- Updated config.ts exports
- Updated test setup with GCP mock
- Documentation: docs/concepts/secrets.md, docs/gateway/secrets.md
- Bootstrap scripts: scripts/secrets/

Usage in openclaw.json:
  ${env:MY_SECRET_VAR}

Split from openclaw#24272 per @vincentkoc's request.
akoscz added a commit to akoscz/openclaw that referenced this pull request Mar 13, 2026
Adds two providers for local/desktop credential storage:

**KeyringSecretProvider** — OS-native keychain:
- macOS: Keychain via `keytar` (lazy-loaded optional peer dep)
- Linux: `secret-tool` CLI (libsecret)
- Supports custom keychain path and account names

**OnePasswordSecretProvider** — 1Password CLI (`op`):
- Uses `op read` to resolve secrets from 1Password vaults
- Configurable vault and field names

Config example:
  "secrets": {
    "providers": {
      "keyring": { "account": "openclaw" },
      "1password": { "vault": "OpenClaw" }
    }
  }

Usage: ${keyring:my-secret} or ${1password:my-item.field}

Split from openclaw#24272 per @vincentkoc's request.
akoscz added a commit to akoscz/openclaw that referenced this pull request Mar 13, 2026
Adds VaultSecretProvider for resolving secrets from HashiCorp Vault.
Uses native `fetch()` — no SDK dependency.

Authentication methods:
- Token auth (VAULT_TOKEN / tokenFile)
- AppRole (roleId + secretId)
- Kubernetes (service account JWT)

Features:
- KV v2 with version pinning: ${vault:path/to/secret#5}
- Namespace support (Vault Enterprise)
- Configurable mount path (default: "secret")
- Lease management for dynamic secrets
- Rotation tracking via KV v2 custom_metadata
- Configurable TTL caching (default 5 min)

Config example:
  "secrets": {
    "providers": {
      "vault": {
        "address": "https://vault.example.com",
        "authMethod": "token",
        "token": "<token>"
      }
    }
  }

Usage: ${vault:my/secret/path}

Split from openclaw#24272 per @vincentkoc's request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands Command implementations docs Improvements or additions to documentation gateway Gateway runtime scripts Repository scripts size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SecretsProvider: env, keyring, 1Password providers (building on #16663)

2 participants