feat(secrets): add SQLite-backed secret store (#121559)

This commit is contained in:
Peter Steinberger
2026-08-10 07:08:40 -07:00
committed by GitHub
parent b121219a44
commit f4bac99a81
81 changed files with 4455 additions and 2611 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"core": 2291, "core": 2292,
"channel": 3716, "channel": 3716,
"plugin": 4030 "plugin": 4030
} }
+2 -2
View File
@@ -1,4 +1,4 @@
6d70719d3e4b0ff86c96852be6efd7ec86cc85cadd07e27e79b2b22c14aed5b9 config-baseline.json 05150969476529ddc78ae3836efb00419571bf706696e91f23af226571fd2562 config-baseline.json
7832494e638045df79a3ba0d25964fb5100f9d7a1e7d8d38a9174f7b6193bf52 config-baseline.core.json 52a8577126e605f70ef6c34cafaaebd83ec7a658b70da20ebb24b6eb37a0e513 config-baseline.core.json
3a8d0cbdbf9d7d603204fba5b93493050cb76fd9141fcbbec56159ff00158d6f config-baseline.channel.json 3a8d0cbdbf9d7d603204fba5b93493050cb76fd9141fcbbec56159ff00158d6f config-baseline.channel.json
4498ac72bb6b9cd5205f7bb8741110a4c53c4e2ec504a1dbb7e7e59c3495f492 config-baseline.plugin.json 4498ac72bb6b9cd5205f7bb8741110a4c53c4e2ec504a1dbb7e7e59c3495f492 config-baseline.plugin.json
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -114,7 +114,7 @@ id (`wsp_...`), slug, or name; the gateway resolves it to the id at startup.
| ----------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | none (required) | Public ClickClack URL used for browser-facing links. | | `baseUrl` | none (required) | Public ClickClack URL used for browser-facing links. |
| `apiBaseUrl` | `baseUrl` | Optional server-to-server endpoint for REST and realtime WebSocket traffic. | | `apiBaseUrl` | `baseUrl` | Optional server-to-server endpoint for REST and realtime WebSocket traffic. |
| `token` | none | Bot token as a plain string or secret ref (`source: "env" \| "file" \| "exec"`). | | `token` | none | Bot token as a plain string or secret ref (`source: "env" \| "file" \| "exec" \| "store"`). |
| `tokenFile` | none | Path to a bot-token file; takes precedence over `token`. | | `tokenFile` | none | Path to a bot-token file; takes precedence over `token`. |
| `workspace` | none (required) | Workspace id, slug, or name. | | `workspace` | none (required) | Workspace id, slug, or name. |
| `replyMode` | `"agent"` | `"agent"` runs the full agent pipeline; `"model"` sends short direct model completions. | | `replyMode` | `"agent"` | `"agent"` runs the full agent pipeline; `"model"` sends short direct model completions. |
+1 -1
View File
@@ -150,7 +150,7 @@ openclaw gateway
DISCORD_BOT_TOKEN=... DISCORD_BOT_TOKEN=...
``` ```
For scripted or remote setup, write the same JSON5 block with `openclaw config patch --file ./discord.patch.json5 --dry-run`, then rerun without `--dry-run`. Plaintext `token` strings work too, and SecretRef values are supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets). For scripted or remote setup, write the same JSON5 block with `openclaw config patch --file ./discord.patch.json5 --dry-run`, then rerun without `--dry-run`. Plaintext `token` strings work too, and SecretRef values are supported for `channels.discord.token` across env/file/exec/store providers. See [Secrets Management](/gateway/secrets).
For multiple Discord bots, keep each bot token and application ID under its account. A top-level `channels.discord.applicationId` is inherited by accounts, so only set it there when every account uses the same application ID. For multiple Discord bots, keep each bot token and application ID under its account. A top-level `channels.discord.applicationId` is inherited by accounts, so only set it there when every account uses the same application ID.
+1 -1
View File
@@ -192,7 +192,7 @@ Use these identifiers for delivery and allowlists:
Notes: Notes:
- Service account credentials: `serviceAccountFile` (path) or `serviceAccount` (inline JSON string, object, or env/file/exec SecretRef). Env vars `GOOGLE_CHAT_SERVICE_ACCOUNT` (inline JSON) and `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE` (path) apply to the default account only. Multi-account setups use `channels.googlechat.accounts.<id>` with the same keys, including per-account `serviceAccount` SecretRefs. - Service account credentials: `serviceAccountFile` (path) or `serviceAccount` (inline JSON string, object, or env/file/exec/store SecretRef). Env vars `GOOGLE_CHAT_SERVICE_ACCOUNT` (inline JSON) and `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE` (path) apply to the default account only. Multi-account setups use `channels.googlechat.accounts.<id>` with the same keys, including per-account `serviceAccount` SecretRefs.
- Default webhook path is `/googlechat` when `webhookPath` is unset; `webhookUrl` can supply the path instead. - Default webhook path is `/googlechat` when `webhookPath` is unset; `webhookUrl` can supply the path instead.
- Group keys must be stable space ids (`spaces/<spaceId>`). Display-name keys are deprecated and logged as such. - Group keys must be stable space ids (`spaces/<spaceId>`). Display-name keys are deprecated and logged as such.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode); doctor warns about email entries. - `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode); doctor warns about email entries.
+1 -1
View File
@@ -847,7 +847,7 @@ Room allowlist keys (`groups`, legacy `rooms`) should be room IDs or aliases. Pl
- `network.dangerouslyAllowPrivateNetwork`: allow this account to connect to `localhost`, LAN/Tailscale IPs, or internal hostnames. - `network.dangerouslyAllowPrivateNetwork`: allow this account to connect to `localhost`, LAN/Tailscale IPs, or internal hostnames.
- `proxy`: optional HTTP(S) proxy URL for Matrix traffic. Per-account override supported. - `proxy`: optional HTTP(S) proxy URL for Matrix traffic. Per-account override supported.
- `userId`: full Matrix user ID (`@bot:example.org`). - `userId`: full Matrix user ID (`@bot:example.org`).
- `accessToken`: access token for token-based auth. Plaintext and SecretRef values supported across env/file/exec providers ([Secrets Management](/gateway/secrets)). - `accessToken`: access token for token-based auth. Plaintext and SecretRef values supported across env/file/exec/store providers ([Secrets Management](/gateway/secrets)).
- `password`: password for password-based login. Plaintext and SecretRef values supported. - `password`: password for password-based login. Plaintext and SecretRef values supported.
- `deviceId`: explicit Matrix device ID. - `deviceId`: explicit Matrix device ID.
- `deviceName`: device display name used at password-login time. - `deviceName`: device display name used at password-login time.
+1 -1
View File
@@ -218,7 +218,7 @@ Then enable the channel in config:
### SecretRef auth token ### SecretRef auth token
`authToken` can be a SecretRef (`source: "env" | "file" | "exec"`). Use this when the Gateway should resolve the Twilio Auth Token from the OpenClaw secrets runtime instead of storing plaintext config: `authToken` can be a SecretRef (`source: "env" | "file" | "exec" | "store"`). Use this when the Gateway should resolve the Twilio Auth Token from the OpenClaw secrets runtime instead of storing plaintext config:
```json5 ```json5
{ {
+2 -2
View File
@@ -224,7 +224,7 @@ Provider builder targets must use `secrets.providers.<alias>` as the path.
<AccordionGroup> <AccordionGroup>
<Accordion title="Common flags"> <Accordion title="Common flags">
- `--provider-source <env|file|exec>` - `--provider-source <env|file|exec|store>`
- `--provider-timeout-ms <ms>` (`file`, `exec`) - `--provider-timeout-ms <ms>` (`file`, `exec`)
</Accordion> </Accordion>
@@ -443,7 +443,7 @@ openclaw config set channels.discord.token \
<Accordion title="If dry-run fails"> <Accordion title="If dry-run fails">
- `config schema validation failed`: your post-change config shape is invalid; fix the path/value or provider/ref object shape. - `config schema validation failed`: your post-change config shape is invalid; fix the path/value or provider/ref object shape.
- `Config policy validation failed: unsupported SecretRef usage`: move that credential back to plaintext/string input; keep SecretRefs on supported surfaces only. - `Config policy validation failed: unsupported SecretRef usage`: move that credential back to plaintext/string input; keep SecretRefs on supported surfaces only.
- `SecretRef assignment(s) could not be resolved`: the referenced provider/ref cannot currently resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch). - `SecretRef assignment(s) could not be resolved`: the referenced provider/ref cannot currently resolve (missing env/store name, invalid file pointer, exec provider failure, or provider/source mismatch).
- `model reference validation failed`: a changed text-model primary or fallback is unknown; run `openclaw models list` and choose an available model. - `model reference validation failed`: a changed text-model primary or fallback is unknown; run `openclaw models list` and choose an available model.
- `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: rerun with `--allow-exec` if you need exec resolvability validation. - `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: rerun with `--allow-exec` if you need exec resolvability validation.
- For batch mode, fix failing entries and rerun `--dry-run` before writing. - For batch mode, fix failing entries and rerun `--dry-run` before writing.
+1 -1
View File
@@ -264,7 +264,7 @@ openclaw onboard --non-interactive \
--accept-risk --accept-risk
``` ```
With `--secret-input-mode ref`, onboarding stores new credentials as env-backed refs instead of plaintext: auth profiles use `keyRef: { source: "env", provider: "default", id: <envVar> }`, and custom providers use `models.providers.<id>.apiKey` (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). Set the provider env var when adding a new credential; an inline key flag without its matching env var fails fast. Existing resolvable named auth profiles and their `env`, `file`, or `exec` references are reused unchanged, without a new `apiKey` or `keyRef` write or additional provider env var. Existing plaintext profile credentials are not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). With `--secret-input-mode ref`, onboarding stores new credentials as env-backed refs instead of plaintext: auth profiles use `keyRef: { source: "env", provider: "default", id: <envVar> }`, and custom providers use `models.providers.<id>.apiKey` (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`). Set the provider env var when adding a new credential; an inline key flag without its matching env var fails fast. Existing resolvable named auth profiles and their `env`, `file`, `exec`, or `store` references are reused unchanged, without a new `apiKey` or `keyRef` write or additional provider env var. Existing plaintext profile credentials are not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets).
### Gateway auth (non-interactive) ### Gateway auth (non-interactive)
+1 -1
View File
@@ -43,7 +43,7 @@ status 1. If no recent session matches, it suggests the picker and
configured Gateway is unavailable, start or repair it and rerun the command. configured Gateway is unavailable, start or repair it and rerun the command.
`resume` resolves configured Gateway auth SecretRefs for token/password auth `resume` resolves configured Gateway auth SecretRefs for token/password auth
when possible (`env`/`file`/`exec` providers). when possible (`env`/`file`/`exec`/`store` providers).
Gateway target precedence is explicit `--url`, then `OPENCLAW_GATEWAY_URL`, Gateway target precedence is explicit `--url`, then `OPENCLAW_GATEWAY_URL`,
then `gateway.remote.url` when `gateway.mode` is `remote`, then the local then `gateway.remote.url` when `gateway.mode` is `remote`, then the local
+83 -3
View File
@@ -1,7 +1,8 @@
--- ---
summary: "CLI reference for `openclaw secrets` (reload, audit, configure, apply)" summary: "CLI reference for `openclaw secrets` (store, reload, audit, configure, apply)"
read_when: read_when:
- Re-resolving secret refs at runtime - Re-resolving secret refs at runtime
- Managing team-scoped values in the shared secret store
- Auditing plaintext residues and unresolved refs - Auditing plaintext residues and unresolved refs
- Configuring SecretRefs and applying one-way scrub changes - Configuring SecretRefs and applying one-way scrub changes
title: "Secrets" title: "Secrets"
@@ -14,6 +15,7 @@ Manage SecretRefs and keep the active runtime snapshot healthy.
| Command | Role | | Command | Role |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reload` | Gateway RPC (`secrets.reload`): re-resolves refs and atomically publishes the owner-aware runtime snapshot (no config writes); eligible owner failures may publish as cold or stale warnings | | `reload` | Gateway RPC (`secrets.reload`): re-resolves refs and atomically publishes the owner-aware runtime snapshot (no config writes); eligible owner failures may publish as cold or stale warnings |
| `store` | Manages team-scoped secret and environment values in the local shared state SQLite database |
| `audit` | Read-only scan of config/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift (exec refs skipped unless `--allow-exec`) | | `audit` | Read-only scan of config/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift (exec refs skipped unless `--allow-exec`) |
| `configure` | Interactive planner for provider setup, target mapping, and preflight (requires a TTY) | | `configure` | Interactive planner for provider setup, target mapping, and preflight (requires a TTY) |
| `apply` | Executes a saved plan (`--dry-run` validates only and skips exec checks by default; write mode rejects exec-containing plans unless `--allow-exec`), then scrubs targeted plaintext residues | | `apply` | Executes a saved plan (`--dry-run` validates only and skips exec checks by default; write mode rejects exec-containing plans unless `--allow-exec`), then scrubs targeted plaintext residues |
@@ -35,9 +37,86 @@ Exit codes for CI/gates:
- `audit --check` returns `1` on findings. - `audit --check` returns `1` on findings.
- Unresolved refs return `2` (regardless of `--check`). - Unresolved refs return `2` (regardless of `--check`).
- Store validation and disclosure-policy failures return `2`; `store get` returns `3` when the name is missing.
Related: [Secrets Management](/gateway/secrets) · [1Password plugin](/plugins/onepassword) · [SecretRef Credential Surface](/reference/secretref-credential-surface) · [Security](/gateway/security) Related: [Secrets Management](/gateway/secrets) · [1Password plugin](/plugins/onepassword) · [SecretRef Credential Surface](/reference/secretref-credential-surface) · [Security](/gateway/security)
## Shared secret store
`openclaw secrets store` writes directly to the local shared state database. The store is Gateway-wide and team-scoped; this release accepts only `--scope team`. `--scope me` is rejected because identity scope arrives with the settings UI.
```bash
openclaw secrets store list
openclaw secrets store set <NAME>
openclaw secrets store get <NAME>
openclaw secrets store rm <NAME>...
openclaw secrets store import [--from <file>]
```
Names must match `^[A-Z][A-Z0-9_]{0,127}$`. Values are limited to 64 KiB (65,536 UTF-8 bytes). `--kind secret|env` overrides automatic kind detection; otherwise names ending in common credential suffixes such as `_API_KEY`, `_TOKEN`, `_PASSWORD`, `_PRIVATE_KEY`, or `_SECRET` become `secret`, and other names become `env`.
### Set values safely
`--value` is accepted only when the resolved kind is `env`:
```bash
openclaw secrets store set LOG_LEVEL --kind env --value debug
```
For `secret` values, `--value` is refused with exit code `2` because command-line arguments can leak through shell history and process listings. Use one of the three safe inputs instead:
- Pipe stdin when stdin is not a TTY.
- Pass `--value-file <path>`; `--value-file -` means stdin.
- Run interactively and enter the value in the no-echo prompt.
Examples:
```bash
op read 'op://Engineering/OpenAI/apiKey' | \
openclaw secrets store set OPENAI_API_KEY --kind secret
openclaw secrets store set TLS_PRIVATE_KEY \
--kind secret \
--value-file ./client-key.pem
```
`set` is idempotent and updates an existing name. Add `--dry-run` to validate and preview the operation without writing. A successful write reminds you to run `openclaw secrets reload` before a config-referenced value can take effect.
### Read values
```bash
openclaw secrets store list --json
openclaw secrets store list --plain
openclaw secrets store get LOG_LEVEL
```
Secret values never appear in human, `--json`, or `--plain` output. `store get` refuses a `secret` entry as write-only by design and exits `2`; it exits `3` when the name does not exist. Environment-kind values are readable.
### Remove values
```bash
openclaw secrets store rm OLD_TOKEN
openclaw secrets store rm OLD_TOKEN LEGACY_PASSWORD --yes
openclaw secrets store rm OLD_TOKEN --dry-run
```
Removal is idempotent, so a missing name succeeds quietly. Without `--yes`, the CLI asks for confirmation. Removed rows are soft-deleted and purged after 30 days.
### Import dotenv files
Import dotenv-format assignments from a regular file or stdin:
```bash
openclaw secrets store import --from .env
openclaw secrets store import --from .env --dry-run
openclaw secrets store import --from .env --yes
op read 'op://Engineering/service-account/dotenv' | openclaw secrets store import --yes
```
The importer supports quoted values and multiline quoted values such as PEM keys. Use `--yes` to skip confirmation and `--dry-run` to inspect the import without writing. Kind detection follows the same name-based rule as `store set`.
The store commands do not accept `--url` or `--token`; Gateway RPC methods are not part of this storage layer.
## Reload runtime snapshot ## Reload runtime snapshot
```bash ```bash
@@ -57,6 +136,7 @@ Scans OpenClaw state for:
- plaintext secret storage - plaintext secret storage
- unresolved refs - unresolved refs
- precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs) - precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs)
- store residue (a team store value duplicated by plaintext in `openclaw.json`)
- generated `agents/*/agent/models.json` residues (provider `apiKey` values and sensitive provider headers) - generated `agents/*/agent/models.json` residues (provider `apiKey` values and sensitive provider headers)
- legacy residues (legacy auth store entries, OAuth reminders) - legacy residues (legacy auth store entries, OAuth reminders)
@@ -75,8 +155,8 @@ Report shape:
- `status`: `clean | findings | unresolved` - `status`: `clean | findings | unresolved`
- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete` - `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete`
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount` - `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `storeResidueCount`, `legacyResidueCount`
- finding codes: `PLAINTEXT_FOUND`, `REF_UNRESOLVED`, `REF_SHADOWED`, `LEGACY_RESIDUE` - finding codes: `PLAINTEXT_FOUND`, `REF_UNRESOLVED`, `REF_SHADOWED`, `STORE_PLAINTEXT_RESIDUE`, `LEGACY_RESIDUE`
## Configure (interactive helper) ## Configure (interactive helper)
+1 -1
View File
@@ -67,7 +67,7 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
ambiguous, the CLI prints candidate names and longer ID prefixes without ambiguous, the CLI prints candidate names and longer ID prefixes without
attaching to either session. attaching to either session.
- With no URL/host target or explicit `--url`, `tui` resolves configured Gateway - With no URL/host target or explicit `--url`, `tui` resolves configured Gateway
auth SecretRefs for token/password auth when possible (`env`/`file`/`exec` auth SecretRefs for token/password auth when possible (`env`/`file`/`exec`/`store`
providers). providers).
- With no explicit URL or port, `tui` follows the active local Gateway port - With no explicit URL or port, `tui` follows the active local Gateway port
recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`, recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`,
+1 -1
View File
@@ -252,7 +252,7 @@ Custom providers configured under `models.providers` are written into `models.js
- A non-empty `baseUrl` already present in the agent `models.json` wins. - A non-empty `baseUrl` already present in the agent `models.json` wins.
- A non-empty `apiKey` in `models.json` wins only when that provider is not SecretRef-managed in the current config/auth-profile context. - A non-empty `apiKey` in `models.json` wins only when that provider is not SecretRef-managed in the current config/auth-profile context.
- SecretRef-managed `apiKey` values refresh from source markers instead of persisting resolved secrets: the env variable name for env refs, `secretref-managed` for file/exec refs. - SecretRef-managed `apiKey` values refresh from source markers instead of persisting resolved secrets: the env variable name for env refs, `secretref-managed` for file/exec/store refs.
- SecretRef-managed header values refresh the same way, using `secretref-env:ENV_VAR_NAME` for env refs. - SecretRef-managed header values refresh the same way, using `secretref-env:ENV_VAR_NAME` for env refs.
- Empty or missing `apiKey`/`baseUrl` in `models.json` fall back to config `models.providers`. - Empty or missing `apiKey`/`baseUrl` in `models.json` fall back to config `models.providers`.
- Other provider fields refresh from config and normalized catalog data. - Other provider fields refresh from config and normalized catalog data.
+1 -1
View File
@@ -13,7 +13,7 @@ This page covers **model provider** authentication (API keys, OAuth, Claude CLI
OpenClaw supports OAuth and API keys for model providers. For an always-on gateway host, an API key is the most predictable option; subscription/OAuth flows work too when they match your provider account model. OpenClaw supports OAuth and API keys for model providers. For an always-on gateway host, an API key is the most predictable option; subscription/OAuth flows work too when they match your provider account model.
- Full OAuth flow and storage layout: [/concepts/oauth](/concepts/oauth) - Full OAuth flow and storage layout: [/concepts/oauth](/concepts/oauth)
- SecretRef-based auth (`env`/`file`/`exec` providers): [Secrets Management](/gateway/secrets) - SecretRef-based auth (`env`/`file`/`exec`/`store` providers): [Secrets Management](/gateway/secrets)
- Credential eligibility/reason codes used by `models status --probe`: [Auth Credential Semantics](/auth-credential-semantics) - Credential eligibility/reason codes used by `models status --probe`: [Auth Credential Semantics](/auth-credential-semantics)
## Recommended setup: API key (any provider) ## Recommended setup: API key (any provider)
+2 -2
View File
@@ -501,8 +501,8 @@ Configuring a custom/local provider `baseUrl` is also the narrow network trust d
- Merge precedence for matching provider IDs: - Merge precedence for matching provider IDs:
- Non-empty agent `models.json` `baseUrl` values win. - Non-empty agent `models.json` `baseUrl` values win.
- Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context. - Non-empty agent `apiKey` values win only when that provider is not SecretRef-managed in current config/auth-profile context.
- SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs) instead of persisting resolved secrets. - SecretRef-managed provider `apiKey` values are refreshed from source markers (`ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec/store refs) instead of persisting resolved secrets.
- SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs). - SecretRef-managed provider header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec/store refs).
- Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config. - Empty or missing agent `apiKey`/`baseUrl` fall back to `models.providers` in config.
- Matching model `contextWindow`/`maxTokens`: the explicit config value wins when present and valid (a positive finite number); otherwise the implicit/generated catalog value is used. - Matching model `contextWindow`/`maxTokens`: the explicit config value wins when present and valid (a positive finite number); otherwise the implicit/generated catalog value is used.
- Matching model `contextTokens` follows the same explicit-wins-else-implicit rule; use it to limit effective context without changing native model metadata. - Matching model `contextTokens` follows the same explicit-wins-else-implicit rule; use it to limit effective context without changing native model metadata.
+1 -1
View File
@@ -1131,7 +1131,7 @@ Secret refs are additive: plaintext values still work.
Use one object shape: Use one object shape:
```json5 ```json5
{ source: "env" | "file" | "exec", provider: "default", id: "..." } { source: "env" | "file" | "exec" | "store", provider: "default", id: "..." }
``` ```
Validation: Validation:
+2 -2
View File
@@ -683,7 +683,7 @@ Rules:
</Accordion> </Accordion>
<Accordion title="Secret refs (env, file, exec)"> <Accordion title="Secret refs (env, file, exec, store)">
For fields that support SecretRef objects, you can use: For fields that support SecretRef objects, you can use:
```json5 ```json5
@@ -716,7 +716,7 @@ Rules:
} }
``` ```
SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets). SecretRef details (including `secrets.providers` for `env`/`file`/`exec`/`store`) are in [Secrets Management](/gateway/secrets).
Supported credential paths are listed in [SecretRef Credential Surface](/reference/secretref-credential-surface). Supported credential paths are listed in [SecretRef Credential Surface](/reference/secretref-credential-surface).
</Accordion> </Accordion>
+60 -5
View File
@@ -1,7 +1,8 @@
--- ---
summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing" summary: "Secrets management: SecretRef contract, shared secret store, runtime snapshots, and safe one-way scrubbing"
read_when: read_when:
- Configuring SecretRefs for provider credentials and `auth-profiles.json` refs - Configuring SecretRefs for provider credentials and `auth-profiles.json` refs
- Storing team-wide secrets and environment values in the shared SQLite store
- Operating secrets reload, audit, configure, and apply safely in production - Operating secrets reload, audit, configure, and apply safely in production
- Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior - Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior
title: "Secrets management" title: "Secrets management"
@@ -99,8 +100,8 @@ The log entry includes the reason the active-surface policy used.
In interactive onboarding, choosing SecretRef storage runs preflight validation before saving: In interactive onboarding, choosing SecretRef storage runs preflight validation before saving:
- Env refs: validates the env var name and confirms a non-empty value is visible during setup. - Env refs: validates the env var name and confirms a non-empty value is visible during setup.
- Provider refs (`file` or `exec`): validates provider selection, resolves `id`, and checks the resolved value type. - Provider refs (`file`, `exec`, or `store`): validates provider selection, resolves `id`, and checks the resolved value type.
- Quickstart flow: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, and `exec` refs) using the same fail-fast gate. - Quickstart flow: when `gateway.auth.token` is already a SecretRef, onboarding resolves it before probe/dashboard bootstrap (for `env`, `file`, `exec`, and `store` refs) using the same fail-fast gate.
Validation failure shows the error and lets you retry. Validation failure shows the error and lets you retry.
@@ -109,7 +110,7 @@ Validation failure shows the error and lets you retry.
One object shape everywhere: One object shape everywhere:
```json5 ```json5
{ source: "env" | "file" | "exec", provider: "default", id: "..." } { source: "env" | "file" | "exec" | "store", provider: "default", id: "..." }
``` ```
<Tabs> <Tabs>
@@ -155,6 +156,18 @@ One object shape everywhere:
- `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected) - `id` must not contain `.` or `..` as slash-delimited path segments (for example `a/../b` is rejected)
</Tab> </Tab>
<Tab title="store">
```json5
{ source: "store", provider: "default", id: "OPENAI_API_KEY" }
```
Validation:
- `provider` must match `^[a-z][a-z0-9_-]{0,63}$`
- `id` uses the environment-name grammar `^[A-Z][A-Z0-9_]{0,127}$`
- This release resolves only the Gateway-wide team scope
</Tab>
</Tabs> </Tabs>
## Provider config ## Provider config
@@ -166,6 +179,7 @@ Define providers under `secrets.providers`:
secrets: { secrets: {
providers: { providers: {
default: { source: "env" }, default: { source: "env" },
teamstore: { source: "store" },
filemain: { filemain: {
source: "file", source: "file",
path: "~/.openclaw/secrets.json", path: "~/.openclaw/secrets.json",
@@ -190,6 +204,7 @@ Define providers under `secrets.providers`:
env: "default", env: "default",
file: "filemain", file: "filemain",
exec: "vault", exec: "vault",
store: "teamstore",
}, },
}, },
} }
@@ -247,6 +262,44 @@ but are not displayed because resolver output can contain credential material.
</Accordion> </Accordion>
<Accordion title="Store provider">
- Reads values from OpenClaw's shared state SQLite database.
- The provider has no connection settings. `secrets.defaults.store` selects its default alias.
- Only team scope is resolved in this release. Identity scope is reserved for a later settings experience.
</Accordion>
## Shared secret store
The shared secret store is a Gateway-wide, team-scoped place for secrets and environment values that should be available to every Gateway process using the same state database. Manage it locally with `openclaw secrets store`; there are no Gateway URL or token options for these commands.
Entries have a `secret` or `env` kind. The kind controls CLI disclosure, not SecretRef resolution:
- `secret` values are write-only through the CLI. List and get output never reveal them.
- `env` values can be returned by `store list` and `store get`.
Names use the same uppercase grammar as env SecretRefs, and each UTF-8 value is limited to 64 KiB (65,536 bytes). This supports PEM keys and service-account JSON without inheriting the smaller limits of ordinary environment variables.
Reference an entry from `openclaw.json` with the `store` source:
```json5
{
models: {
providers: {
openai: {
apiKey: { source: "store", provider: "default", id: "OPENAI_API_KEY" },
},
},
},
}
```
After changing a value used by config, run `openclaw secrets reload` so the active in-memory snapshot picks it up.
<Warning>
Store values are not encrypted at rest. They are stored unencrypted in the shared state SQLite database (`state/openclaw.sqlite`), protected by the same `0600` file and `0700` directory permissions as other credentials in that database. Operators who need stronger storage isolation should use an external exec provider such as the [1Password plugin](/plugins/onepassword) or [Vault SecretRefs](/plugins/vault).
</Warning>
## File-backed API keys ## File-backed API keys
Do not put `file:...` strings in the config `env` block. That block is literal and non-overriding, so `file:...` is never resolved there. Do not put `file:...` strings in the config `env` block. That block is literal and non-overriding, so `file:...` is never resolved there.
@@ -586,6 +639,7 @@ Warning and audit signals:
- `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning) - `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning)
- `REF_SHADOWED` (audit finding when SQLite auth-profile credentials take precedence over `openclaw.json` refs) - `REF_SHADOWED` (audit finding when SQLite auth-profile credentials take precedence over `openclaw.json` refs)
- `STORE_PLAINTEXT_RESIDUE` (audit finding when a stored name still has an equivalent plaintext config value)
Google Chat `serviceAccount` accepts inline JSON or a SecretRef. Doctor moves the retired sibling `serviceAccountRef` into this canonical field when it is unset. Google Chat `serviceAccount` accepts inline JSON or a SecretRef. Doctor moves the retired sibling `serviceAccountRef` into this canonical field when it is unset.
@@ -685,6 +739,7 @@ If you save a plan instead of applying during `configure`, apply that saved plan
- Plaintext sensitive provider header residues in generated `models.json` entries. - Plaintext sensitive provider header residues in generated `models.json` entries.
- Unresolved refs. - Unresolved refs.
- Precedence shadowing (SQLite auth profiles taking priority over `openclaw.json` refs). - Precedence shadowing (SQLite auth profiles taking priority over `openclaw.json` refs).
- Store residue (a stored name still has an equivalent plaintext value in config).
Exec note: by default, audit skips exec SecretRef resolvability checks to avoid command side effects. Use `openclaw secrets audit --allow-exec` to execute exec providers during audit. Exec note: by default, audit skips exec SecretRef resolvability checks to avoid command side effects. Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
@@ -694,7 +749,7 @@ If you save a plan instead of applying during `configure`, apply that saved plan
<Accordion title="secrets configure"> <Accordion title="secrets configure">
Interactive helper that: Interactive helper that:
- Configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove). - Configures `secrets.providers` first (`env`/`file`/`exec`/`store`, add/edit/remove).
- Lets you select supported secret-bearing fields in `openclaw.json` plus the SQLite auth-profile store for one agent scope. - Lets you select supported secret-bearing fields in `openclaw.json` plus the SQLite auth-profile store for one agent scope.
- Can create a new auth-profile mapping directly in the target picker. - Can create a new auth-profile mapping directly in the target picker.
- Captures SecretRef details (`source`, `provider`, `id`). - Captures SecretRef details (`source`, `provider`, `id`).
+1 -1
View File
@@ -763,7 +763,7 @@ Also useful for backup decisions:
- WhatsApp: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json` - WhatsApp: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- Telegram bot token: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected) - Telegram bot token: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- Discord bot token: config/env or SecretRef (env/file/exec providers) - Discord bot token: config/env or SecretRef (env/file/exec/store providers)
- Slack tokens: config/env (`channels.slack.*`) - Slack tokens: config/env (`channels.slack.*`)
- Pairing allowlists: `~/.openclaw/credentials/<channel>-allowFrom.json` (default account) / `<channel>-<accountId>-allowFrom.json` (non-default accounts) - Pairing allowlists: `~/.openclaw/credentials/<channel>-allowFrom.json` (default account) / `<channel>-<accountId>-allowFrom.json` (non-default accounts)
- Model auth profiles: `~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite` (`auth_profile_store`) - Model auth profiles: `~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite` (`auth_profile_store`)
+1 -1
View File
@@ -56,7 +56,7 @@ Route fields:
| `controllerId` | no | `webhooks/<routeId>` | Used as the default `create_flow` controller. | | `controllerId` | no | `webhooks/<routeId>` | Used as the default `create_flow` controller. |
| `description` | no | - | Operator note only. | | `description` | no | - | Operator note only. |
`secret` accepts a plain string or a SecretRef: `{ source: "env" | "file" | "exec", provider: "default", id: "..." }`. `secret` accepts a plain string or a SecretRef: `{ source: "env" | "file" | "exec" | "store", provider: "default", id: "..." }`.
SecretRefs resolve into the Gateway's startup config snapshot. When one route's SecretRefs resolve into the Gateway's startup config snapshot. When one route's
secret cannot resolve, the Gateway keeps running and that exact route stays secret cannot resolve, the Gateway keeps running and that exact route stays
@@ -7,7 +7,7 @@ read_when:
title: "SecretRef credential surface" title: "SecretRef credential surface"
--- ---
This page defines the canonical SecretRef credential surface: which credential fields accept a `SecretRef` (env/file/exec-backed reference) instead of a raw secret value. This page defines the canonical SecretRef credential surface: which credential fields accept a `SecretRef` (env/file/exec/store-backed reference) instead of a raw secret value.
Scope: Scope:
@@ -133,6 +133,7 @@ The lists below are generated from the source target registry and checked agains
Notes: Notes:
- Store refs use names matching `^[A-Z][A-Z0-9_]{0,127}$` and resolve only from the Gateway-wide team scope in this release. A typical ref is `{"source":"store","provider":"default","id":"OPENAI_API_KEY"}`.
- Auth-profile plan targets require `agentId`; plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`). Auth-profile refs are included in runtime resolution and audit coverage. - Auth-profile plan targets require `agentId`; plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`). Auth-profile refs are included in runtime resolution and audit coverage.
- In `openclaw.json`, SecretRefs must use structured objects such as `{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}`. Legacy `secretref-env:<ENV_VAR>` marker strings are rejected on SecretRef credential paths; run `openclaw doctor --fix` to migrate valid markers. - In `openclaw.json`, SecretRefs must use structured objects such as `{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}`. Legacy `secretref-env:<ENV_VAR>` marker strings are rejected on SecretRef credential paths; run `openclaw doctor --fix` to migrate valid markers.
- OAuth policy guard: `auth.profiles.<id>.mode = "oauth"` cannot be combined with SecretRef inputs for that profile. Startup/reload and auth-profile resolution fail fast when this policy is violated. - OAuth policy guard: `auth.profiles.<id>.mode = "oauth"` cannot be combined with SecretRef inputs for that profile. Startup/reload and auth-profile resolution fail fast when this policy is violated.
+1 -1
View File
@@ -96,7 +96,7 @@ behavior and outputs, see [CLI setup reference](/start/wizard-cli-reference).
- In token mode, interactive setup offers: - In token mode, interactive setup offers:
- **Generate/store plaintext token** (default) - **Generate/store plaintext token** (default)
- **Use SecretRef** (opt-in) - **Use SecretRef** (opt-in)
- Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for onboarding probe/dashboard bootstrap. - Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, `exec`, and `store` providers for onboarding probe/dashboard bootstrap.
- If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth. - If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth.
- In password mode, interactive setup also supports plaintext or SecretRef storage. - In password mode, interactive setup also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`. - Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
+1 -1
View File
@@ -147,7 +147,7 @@ Use this when debugging auth or deciding what to back up:
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json` - **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected) - **Telegram bot token**: config/env or `channels.telegram.tokenFile` (regular file only; symlinks rejected)
- **Discord bot token**: config/env or SecretRef (env/file/exec providers) - **Discord bot token**: config/env or SecretRef (env/file/exec/store providers)
- **Slack tokens**: config/env (`channels.slack.*`) - **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**: - **Pairing allowlists**:
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account) - `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
+1 -1
View File
@@ -32,7 +32,7 @@ Add `--json` for a machine-readable summary.
- `--gateway-port` defaults to `18789`; only pass it to override. - `--gateway-port` defaults to `18789`; only pass it to override.
- `--skip-bootstrap` skips creating default workspace files, for automation that pre-seeds its own workspace. - `--skip-bootstrap` skips creating default workspace files, for automation that pre-seeds its own workspace.
- `--secret-input-mode ref` stores new credentials as env-backed references (`{ source: "env", provider: "default", id: "<ENV_VAR>" }`); set the provider env var when adding a credential or passing an inline key flag. Existing resolvable named profiles and their `env`, `file`, or `exec` references are reused unchanged, without a new credential write or additional provider env var. Existing plaintext is not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). - `--secret-input-mode ref` stores new credentials as env-backed references (`{ source: "env", provider: "default", id: "<ENV_VAR>" }`); set the provider env var when adding a credential or passing an inline key flag. Existing resolvable named profiles and their `env`, `file`, `exec`, or `store` references are reused unchanged, without a new credential write or additional provider env var. Existing plaintext is not migrated; run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets).
```bash ```bash
openclaw onboard --non-interactive --accept-risk \ openclaw onboard --non-interactive --accept-risk \
+1 -1
View File
@@ -303,7 +303,7 @@ Credential storage mode:
- In non-interactive mode, `--secret-input-mode ref` creates only env-backed references for new credentials. - In non-interactive mode, `--secret-input-mode ref` creates only env-backed references for new credentials.
- Set the provider env var in the onboarding process environment when adding a new credential. - Set the provider env var in the onboarding process environment when adding a new credential.
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast. - Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast.
- Existing resolvable named auth profiles are reused unchanged, including existing `env`, `file`, and `exec` references; no new `apiKey` or `keyRef` is written and no additional provider env var is required. - Existing resolvable named auth profiles are reused unchanged, including existing `env`, `file`, `exec`, and `store` references; no new `apiKey` or `keyRef` is written and no additional provider env var is required.
- For new custom-provider credentials, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`. - For new custom-provider credentials, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast. - In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast.
- Existing plaintext profile credentials remain unchanged; reference mode does not migrate them. Run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets). - Existing plaintext profile credentials remain unchanged; reference mode does not migrate them. Run `openclaw secrets configure --apply`, then `openclaw secrets audit --check`. See [Secrets management](/gateway/secrets).
+1 -1
View File
@@ -161,7 +161,7 @@ Local mode (default) walks through these steps:
tool policy strict - weaker or older tiers are easier to prompt-inject. tool policy strict - weaker or older tiers are easier to prompt-inject.
For non-interactive runs, `--secret-input-mode ref` stores new credentials For non-interactive runs, `--secret-input-mode ref` stores new credentials
as env-backed refs; set the provider env var when adding a credential. as env-backed refs; set the provider env var when adding a credential.
Existing resolvable named profiles and their `env`, `file`, or `exec` refs Existing resolvable named profiles and their `env`, `file`, `exec`, or `store` refs
are reused unchanged without a new credential write or additional provider are reused unchanged without a new credential write or additional provider
env var. Previously stored plaintext is not migrated; see env var. Previously stored plaintext is not migrated; see
[Secrets management](/gateway/secrets). Interactive secret reference mode can [Secrets management](/gateway/secrets). Interactive secret reference mode can
+1 -1
View File
@@ -124,7 +124,7 @@
"properties": { "properties": {
"source": { "source": {
"type": "string", "type": "string",
"enum": ["env", "file", "exec"] "enum": ["env", "file", "exec", "store"]
}, },
"provider": { "type": "string" }, "provider": { "type": "string" },
"id": { "type": "string" } "id": { "type": "string" }
@@ -500,6 +500,9 @@ function secretRefDefaults(value: unknown): SecretRefDefaults | undefined {
if (typeof value.exec === "string") { if (typeof value.exec === "string") {
defaults.exec = value.exec; defaults.exec = value.exec;
} }
if (typeof value.store === "string") {
defaults.store = value.store;
}
return defaults; return defaults;
} }
+2 -2
View File
@@ -192,7 +192,7 @@ export type PolicySecretEvidence = {
readonly kind: "input" | "provider"; readonly kind: "input" | "provider";
readonly source: string; readonly source: string;
readonly provenance?: "secretRef"; readonly provenance?: "secretRef";
readonly refSource?: "env" | "file" | "exec"; readonly refSource?: "env" | "file" | "exec" | "store";
readonly refProvider?: string; readonly refProvider?: string;
readonly providerSource?: string; readonly providerSource?: string;
readonly insecure?: readonly string[]; readonly insecure?: readonly string[];
@@ -236,7 +236,7 @@ export type PolicyDataHandlingEvidence = {
}; };
export type SecretRefEvidence = { export type SecretRefEvidence = {
readonly source: "env" | "file" | "exec"; readonly source: "env" | "file" | "exec" | "store";
readonly provider: string; readonly provider: string;
readonly id: string; readonly id: string;
}; };
@@ -37,7 +37,7 @@ type QaAuthProfileCredential =
}; };
type QaSecretRef = { type QaSecretRef = {
source: "env" | "file" | "exec"; source: "env" | "file" | "exec" | "store";
provider?: string; provider?: string;
id: string; id: string;
}; };
+1 -1
View File
@@ -52,7 +52,7 @@
"properties": { "properties": {
"source": { "source": {
"type": "string", "type": "string",
"enum": ["env", "file", "exec"] "enum": ["env", "file", "exec", "store"]
}, },
"provider": { "type": "string" }, "provider": { "type": "string" },
"id": { "type": "string" } "id": { "type": "string" }
+1 -1
View File
@@ -26,7 +26,7 @@
"properties": { "properties": {
"source": { "source": {
"type": "string", "type": "string",
"enum": ["env", "file", "exec"] "enum": ["env", "file", "exec", "store"]
}, },
"provider": { "type": "string" }, "provider": { "type": "string" },
"id": { "type": "string" } "id": { "type": "string" }
+1 -1
View File
@@ -4,7 +4,7 @@ import { normalizeWebhookPath } from "../runtime-api.js";
const secretRefSchema = z const secretRefSchema = z
.object({ .object({
source: z.enum(["env", "file", "exec"]), source: z.enum(["env", "file", "exec", "store"]),
provider: z.string().trim().min(1), provider: z.string().trim().min(1),
id: z.string().trim().min(1), id: z.string().trim().min(1),
}) })
@@ -27,6 +27,9 @@ describe("gateway protocol SecretRef schema", () => {
id: "/providers/openai/apiKey", id: "/providers/openai/apiKey",
}), }),
).toBe(true); ).toBe(true);
expect(
validateSecretRef.Check({ source: "store", provider: "default", id: "STORED_API_KEY" }),
).toBe(true);
for (const id of VALID_EXEC_SECRET_REF_IDS) { for (const id of VALID_EXEC_SECRET_REF_IDS) {
expect(validateSecretRef.Check({ source: "exec", provider: "vault", id }), id).toBe(true); expect(validateSecretRef.Check({ source: "exec", provider: "vault", id }), id).toBe(true);
expect(validateSecretInput.Check({ source: "exec", provider: "vault", id }), id).toBe(true); expect(validateSecretInput.Check({ source: "exec", provider: "vault", id }), id).toBe(true);
@@ -39,4 +42,10 @@ describe("gateway protocol SecretRef schema", () => {
expect(validateSecretInput.Check({ source: "exec", provider: "vault", id }), id).toBe(false); expect(validateSecretInput.Check({ source: "exec", provider: "vault", id }), id).toBe(false);
} }
}); });
it("rejects store refs outside the env-name grammar", () => {
expect(validateSecretRef.Check({ source: "store", provider: "default", id: "lowercase" })).toBe(
false,
);
});
}); });
@@ -84,11 +84,18 @@ const ExecSecretRefSchema = closedObject({
id: Type.String({ pattern: EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN }), id: Type.String({ pattern: EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN }),
}); });
const StoreSecretRefSchema = closedObject({
source: Type.Literal("store"),
provider: SecretProviderAliasString,
id: Type.String({ pattern: ENV_SECRET_REF_ID_RE.source }),
});
/** Structured secret reference accepted by config and channel protocol payloads. */ /** Structured secret reference accepted by config and channel protocol payloads. */
export const SecretRefSchema = Type.Union([ export const SecretRefSchema = Type.Union([
EnvSecretRefSchema, EnvSecretRefSchema,
FileSecretRefSchema, FileSecretRefSchema,
ExecSecretRefSchema, ExecSecretRefSchema,
StoreSecretRefSchema,
]); ]);
/** Secret input value: either an inline string or a structured SecretRef. */ /** Secret input value: either an inline string or a structured SecretRef. */
@@ -1,7 +1,7 @@
/** Canonical id for file secret providers that expose exactly one value. */ /** Canonical id for file secret providers that expose exactly one value. */
export const SINGLE_VALUE_FILE_REF_ID = "value"; export const SINGLE_VALUE_FILE_REF_ID = "value";
/** Shared alias grammar for env/file/exec secret provider names. */ /** Shared alias grammar for env/file/exec/store secret provider names. */
export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
/** JSON-schema fragment that rejects absolute file secret ref ids. */ /** JSON-schema fragment that rejects absolute file secret ref ids. */
export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/"; export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/";
@@ -6,7 +6,7 @@ import {
} from "@openclaw/normalization-core/string-coerce"; } from "@openclaw/normalization-core/string-coerce";
/** Supported secret reference backing stores. */ /** Supported secret reference backing stores. */
type SecretRefSource = "env" | "file" | "exec"; type SecretRefSource = "env" | "file" | "exec" | "store";
/** Canonical secret reference shape used after gateway resolution. */ /** Canonical secret reference shape used after gateway resolution. */
type SecretRef = { type SecretRef = {
@@ -19,7 +19,7 @@ const DEFAULT_SECRET_PROVIDER_ALIAS = "default";
const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/; const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:"; const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:";
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/; const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
const SECRET_REF_SOURCES = new Set<SecretRefSource>(["env", "file", "exec"]); const SECRET_REF_SOURCES = new Set<SecretRefSource>(["env", "file", "exec", "store"]);
/** Narrow a string to a supported SecretRef source. */ /** Narrow a string to a supported SecretRef source. */
function hasSecretRefSource(value: unknown): value is SecretRefSource { function hasSecretRefSource(value: unknown): value is SecretRefSource {
@@ -11,6 +11,20 @@ const cfg = {
} satisfies OpenClawConfig; } satisfies OpenClawConfig;
describe("resolveStoredCredentialReadOnlyAvailability", () => { describe("resolveStoredCredentialReadOnlyAvailability", () => {
it("keeps an implicit store ref unknown until runtime resolution", () => {
expect(
resolveStoredCredentialReadOnlyAvailability({
credential: {
type: "api_key",
provider: "test",
keyRef: { source: "store", provider: "default", id: "STORED_API_KEY" },
},
cfg: {},
env: {},
}),
).toBeUndefined();
});
it("prefers explicit secret refs over retained inline values", () => { it("prefers explicit secret refs over retained inline values", () => {
expect( expect(
resolveStoredCredentialReadOnlyAvailability({ resolveStoredCredentialReadOnlyAvailability({
@@ -42,12 +42,11 @@ export function resolveSecretRefReadOnlyAvailability(
return false; return false;
} }
const source = cfg.secrets?.providers?.[value.provider]; const source = cfg.secrets?.providers?.[value.provider];
if ( const isImplicitProvider =
(!source && (value.source === "env" && value.provider === resolveDefaultSecretProviderAlias(cfg, "env")) ||
(value.source !== "env" || (value.source === "store" &&
value.provider !== resolveDefaultSecretProviderAlias(cfg, "env"))) || value.provider === resolveDefaultSecretProviderAlias(cfg, "store"));
(source && source.source !== value.source) if ((!source && !isImplicitProvider) || (source && source.source !== value.source)) {
) {
return false; return false;
} }
if (value.source === "env") { if (value.source === "env") {
+2 -1
View File
@@ -7,6 +7,7 @@
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
import { isInstalledPluginEnabled } from "../../plugins/installed-plugin-index.js"; import { isInstalledPluginEnabled } from "../../plugins/installed-plugin-index.js";
import { listKnownSecretEnvVarNames } from "../../secrets/provider-env-vars.js"; import { listKnownSecretEnvVarNames } from "../../secrets/provider-env-vars.js";
import { SECRET_ENV_NAME_RE } from "../../secrets/secret-env-name.js";
import { SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH } from "./config-hash.js"; import { SANDBOX_DOCKER_EXPLICIT_ENV_POLICY_EPOCH } from "./config-hash.js";
const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [ const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
@@ -27,7 +28,7 @@ const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
/^(GH|GITHUB)_TOKEN$/i, /^(GH|GITHUB)_TOKEN$/i,
/^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i, /^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i,
/_ADMIN_KEY$/i, /_ADMIN_KEY$/i,
/_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i, SECRET_ENV_NAME_RE,
]; ];
const ALLOWED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [ const ALLOWED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
+10 -3
View File
@@ -91,10 +91,10 @@ export function configPatchModeError(message: string): Error {
function parseSecretRefSource(raw: string, label: string): SecretRefSource { function parseSecretRefSource(raw: string, label: string): SecretRefSource {
const source = raw.trim(); const source = raw.trim();
if (source === "env" || source === "file" || source === "exec") { if (source === "env" || source === "file" || source === "exec" || source === "store") {
return source; return source;
} }
throw new Error(`${label} must be one of: env, file, exec.`); throw new Error(`${label} must be one of: env, file, exec, store.`);
} }
function parseSecretRefBuilder(params: { function parseSecretRefBuilder(params: {
@@ -121,6 +121,11 @@ function parseSecretRefBuilder(params: {
if (source === "env" && !isValidEnvSecretRefId(id)) { if (source === "env" && !isValidEnvSecretRefId(id)) {
throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`); throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`);
} }
if (source === "store" && !isValidEnvSecretRefId(id)) {
throw new Error(
`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for store refs.`,
);
}
if (source === "file" && !isValidFileSecretRefId(id)) { if (source === "file" && !isValidFileSecretRefId(id)) {
throw new Error( throw new Error(
`${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`, `${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`,
@@ -233,6 +238,8 @@ function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig
...(timeoutMs !== undefined ? { timeoutMs } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(maxBytes !== undefined ? { maxBytes } : {}), ...(maxBytes !== undefined ? { maxBytes } : {}),
}; };
} else if (source === "store") {
provider = { source: "store" };
} else { } else {
const command = opts.providerCommand?.trim(); const command = opts.providerCommand?.trim();
if (!command) { if (!command) {
@@ -418,7 +425,7 @@ function buildSingleSetOperations(params: {
} }
if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) { if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) {
throw modeError( throw modeError(
"ref builder mode requires --ref-provider <alias>, --ref-source <env|file|exec>, and --ref-id <id>.", "ref builder mode requires --ref-provider <alias>, --ref-source <env|file|exec|store>, and --ref-id <id>.",
); );
} }
return [ return [
+2 -2
View File
@@ -435,9 +435,9 @@ export function registerConfigCli(program: Command) {
false, false,
) )
.option("--ref-provider <alias>", "SecretRef builder: provider alias") .option("--ref-provider <alias>", "SecretRef builder: provider alias")
.option("--ref-source <source>", "SecretRef builder: source (env|file|exec)") .option("--ref-source <source>", "SecretRef builder: source (env|file|exec|store)")
.option("--ref-id <id>", "SecretRef builder: ref id") .option("--ref-id <id>", "SecretRef builder: ref id")
.option("--provider-source <source>", "Provider builder: source (env|file|exec)") .option("--provider-source <source>", "Provider builder: source (env|file|exec|store)")
.option( .option(
"--provider-allowlist <envVar>", "--provider-allowlist <envVar>",
"Provider builder (env): allowlist entry (repeatable)", "Provider builder (env): allowlist entry (repeatable)",
@@ -85,6 +85,7 @@ const JSON_NOT_APPLICABLE = {
"directory groups", "directory groups",
"security", "security",
"secrets", "secrets",
"secrets store",
"models aliases", "models aliases",
"models fallbacks", "models fallbacks",
"models image-fallbacks", "models image-fallbacks",
@@ -197,6 +198,9 @@ const JSON_NOT_APPLICABLE = {
"channels remove", "channels remove",
"channels login", "channels login",
"channels logout", "channels logout",
"secrets store set",
"secrets store rm",
"secrets store import",
], ],
}, },
rawArtifacts: { rawArtifacts: {
+2
View File
@@ -240,6 +240,7 @@ describe("secrets CLI", () => {
plaintextCount: 1, plaintextCount: 1,
unresolvedRefCount: 0, unresolvedRefCount: 0,
shadowedRefCount: 0, shadowedRefCount: 0,
storeResidueCount: 0,
legacyResidueCount: 0, legacyResidueCount: 0,
}, },
resolution: { resolution: {
@@ -271,6 +272,7 @@ describe("secrets CLI", () => {
plaintextCount: 0, plaintextCount: 0,
unresolvedRefCount: 0, unresolvedRefCount: 0,
shadowedRefCount: 0, shadowedRefCount: 0,
storeResidueCount: 0,
legacyResidueCount: 0, legacyResidueCount: 0,
}, },
resolution: { resolution: {
+4 -1
View File
@@ -10,6 +10,7 @@ import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { formatGatewayCommandFailure } from "./error-format.js"; import { formatGatewayCommandFailure } from "./error-format.js";
import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js";
import { registerSecretStoreCli } from "./secrets-store-cli.js";
type FsModule = typeof import("node:fs"); type FsModule = typeof import("node:fs");
type ClackPromptsModule = typeof import("@clack/prompts"); type ClackPromptsModule = typeof import("@clack/prompts");
@@ -117,6 +118,8 @@ export function registerSecretsCli(program: Command): void {
`\n${theme.muted("Docs:")} ${formatDocsLink("/gateway/security", "docs.openclaw.ai/gateway/security")}\n`, `\n${theme.muted("Docs:")} ${formatDocsLink("/gateway/security", "docs.openclaw.ai/gateway/security")}\n`,
); );
registerSecretStoreCli(secrets);
addGatewayClientOptions( addGatewayClientOptions(
secrets secrets
.command("reload") .command("reload")
@@ -174,7 +177,7 @@ export function registerSecretsCli(program: Command): void {
defaultRuntime.writeJson(report); defaultRuntime.writeJson(report);
} else { } else {
defaultRuntime.log( defaultRuntime.log(
`Secrets audit: ${report.status}. plaintext=${report.summary.plaintextCount}, unresolved=${report.summary.unresolvedRefCount}, shadowed=${report.summary.shadowedRefCount}, legacy=${report.summary.legacyResidueCount}.`, `Secrets audit: ${report.status}. plaintext=${report.summary.plaintextCount}, unresolved=${report.summary.unresolvedRefCount}, shadowed=${report.summary.shadowedRefCount}, storeResidue=${report.summary.storeResidueCount}, legacy=${report.summary.legacyResidueCount}.`,
); );
if (report.findings.length > 0) { if (report.findings.length > 0) {
for (const finding of report.findings.slice(0, 20)) { for (const finding of report.findings.slice(0, 20)) {
+158
View File
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSecretsCli } from "./secrets-cli.js";
const mocks = await vi.hoisted(async () => {
const { createCliRuntimeMock } = await import("./test-runtime-mock.js");
return {
...createCliRuntimeMock(vi),
list: vi.fn(),
read: vi.fn(),
write: vi.fn(),
remove: vi.fn(),
purge: vi.fn(),
gatewayIdentity: vi.fn(),
confirm: vi.fn(),
};
});
vi.mock("../runtime.js", () => ({ defaultRuntime: mocks.defaultRuntime }));
vi.mock("../secrets/store/secret-store.js", () => ({
SECRET_STORE_VALUE_MAX_BYTES: 64 * 1024,
listSecretStoreEntries: (params: unknown) => mocks.list(params),
readSecretStoreValue: (params: unknown) => mocks.read(params),
writeSecretStoreEntry: (params: unknown) => mocks.write(params),
deleteSecretStoreEntry: (params: unknown) => mocks.remove(params),
purgeExpiredSecretStoreEntries: () => mocks.purge(),
}));
vi.mock("../infra/gateway-lock.js", () => ({
readActiveGatewayLockIdentity: () => mocks.gatewayIdentity(),
}));
vi.mock("@clack/prompts", async (importOriginal) => {
const actual = await importOriginal<typeof import("@clack/prompts")>();
return {
...actual,
confirm: (options: unknown) => mocks.confirm(options),
isCancel: (value: unknown) => typeof value === "symbol",
};
});
function createProgram(): Command {
const program = new Command();
program.exitOverride();
registerSecretsCli(program);
return program;
}
beforeEach(() => {
mocks.runtimeLogs.length = 0;
mocks.runtimeErrors.length = 0;
mocks.list.mockReset();
mocks.read.mockReset();
mocks.write.mockReset();
mocks.remove.mockReset();
mocks.purge.mockReset();
mocks.gatewayIdentity.mockReset().mockResolvedValue(undefined);
mocks.confirm.mockReset().mockResolvedValue(true);
mocks.defaultRuntime.log.mockClear();
mocks.defaultRuntime.error.mockClear();
mocks.defaultRuntime.writeStdout.mockClear();
mocks.defaultRuntime.writeJson.mockClear();
mocks.defaultRuntime.exit.mockClear();
});
describe("secrets store CLI", () => {
it("refuses --value for secret entries with all safe alternatives and exit 2", async () => {
await expect(
createProgram().parseAsync(
["secrets", "store", "set", "SERVICE_API_KEY", "--kind", "secret", "--value", "leaked"],
{ from: "user" },
),
).rejects.toThrow("__exit__:2");
expect(mocks.runtimeErrors.join("\n")).toContain("stdin pipe");
expect(mocks.runtimeErrors.join("\n")).toContain("--value-file");
expect(mocks.runtimeErrors.join("\n")).toContain("interactive no-echo prompt");
expect(mocks.write).not.toHaveBeenCalled();
});
it("refuses get for secret entries without reading their values", async () => {
mocks.list.mockReturnValue([{ name: "SERVICE_API_KEY", kind: "secret" }]);
await expect(
createProgram().parseAsync(["secrets", "store", "get", "SERVICE_API_KEY"], {
from: "user",
}),
).rejects.toThrow("__exit__:2");
expect(mocks.runtimeErrors.join("\n")).toContain("write-only by design");
expect(mocks.read).not.toHaveBeenCalled();
});
it("returns exit 3 for a missing get and exit 1 for a database failure", async () => {
mocks.list.mockReturnValueOnce([]);
await expect(
createProgram().parseAsync(["secrets", "store", "get", "MISSING_VALUE"], {
from: "user",
}),
).rejects.toThrow("__exit__:3");
mocks.list.mockImplementationOnce(() => {
throw new Error("database unavailable");
});
await expect(
createProgram().parseAsync(["secrets", "store", "list"], { from: "user" }),
).rejects.toThrow("__exit__:1");
});
it("keeps rm idempotent when entries are already missing", async () => {
await createProgram().parseAsync(["secrets", "store", "rm", "MISSING_VALUE", "--yes"], {
from: "user",
});
await createProgram().parseAsync(["secrets", "store", "rm", "MISSING_VALUE", "--yes"], {
from: "user",
});
expect(mocks.remove).toHaveBeenCalledTimes(2);
expect(mocks.runtimeErrors).toEqual([]);
});
it("imports quoted and multiline dotenv values without exposing them", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-store-import-"));
const dotenvPath = path.join(root, "values.env");
await fs.writeFile(
dotenvPath,
[
'SERVICE_URL="https://service.test/path with spaces"',
'SERVICE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----',
"multiline-body",
'-----END PRIVATE KEY-----"',
].join("\n"),
"utf8",
);
try {
await createProgram().parseAsync(
["secrets", "store", "import", "--from", dotenvPath, "--yes"],
{ from: "user" },
);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
expect(mocks.write).toHaveBeenCalledTimes(2);
expect(mocks.write.mock.calls[0]?.[0]).toMatchObject({
name: "SERVICE_URL",
value: "https://service.test/path with spaces",
kind: "env",
});
expect(mocks.write.mock.calls[1]?.[0]).toMatchObject({
name: "SERVICE_PRIVATE_KEY",
value: "-----BEGIN PRIVATE KEY-----\nmultiline-body\n-----END PRIVATE KEY-----",
kind: "secret",
});
const output = [...mocks.runtimeLogs, ...mocks.runtimeErrors].join("\n");
expect(output).not.toContain("multiline-body");
});
});
+346
View File
@@ -0,0 +1,346 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { ENV_SECRET_REF_ID_RE } from "../config/types.secrets.js";
import { danger } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { defaultRuntime } from "../runtime.js";
import { isSensitiveEnvName } from "../secrets/secret-env-name.js";
import type {
SecretStoreEntryMetadata,
SecretStoreValidationError,
} from "../secrets/store/secret-store.js";
type OutputOptions = { json?: boolean; plain?: boolean; scope?: string };
type SetOptions = {
value?: string;
valueFile?: string;
kind?: string;
scope?: string;
dryRun?: boolean;
};
type RemoveOptions = { scope?: string; dryRun?: boolean; yes?: boolean };
type ImportOptions = RemoveOptions & { from?: string; kind?: string };
type StoreKind = "secret" | "env";
class SecretStoreCliFailure extends Error {
constructor(
readonly exitCode: 1 | 2 | 3,
message: string,
) {
super(message);
this.name = "SecretStoreCliFailure";
}
}
function teamScope(scope: string | undefined): { kind: "team" } {
if (!scope || scope === "team") {
return { kind: "team" };
}
if (scope === "me") {
throw new SecretStoreCliFailure(
2,
"Identity scope arrives with the settings UI; use --scope team.",
);
}
throw new SecretStoreCliFailure(2, `Invalid scope "${scope}"; only "team" is supported.`);
}
function storeKind(kind: string | undefined, name: string): StoreKind {
if (!kind) {
return isSensitiveEnvName(name) ? "secret" : "env";
}
if (kind === "secret" || kind === "env") {
return kind;
}
throw new SecretStoreCliFailure(2, `Invalid kind "${kind}"; use "secret" or "env".`);
}
function assertStoreName(name: string): void {
if (!ENV_SECRET_REF_ID_RE.test(name)) {
throw new SecretStoreCliFailure(2, `Name must match ${String(ENV_SECRET_REF_ID_RE)}.`);
}
}
function assertOutputMode(options: OutputOptions): void {
if (options.json && options.plain) {
throw new SecretStoreCliFailure(2, "Choose either --json or --plain, not both.");
}
}
function mapStoreError(error: unknown): SecretStoreCliFailure {
if (error instanceof SecretStoreCliFailure) {
return error;
}
const validation = error as Partial<SecretStoreValidationError>;
if (
validation?.name === "SecretStoreValidationError" &&
(validation.code === "SECRET_STORE_INVALID_NAME" ||
validation.code === "SECRET_STORE_VALUE_TOO_LARGE")
) {
return new SecretStoreCliFailure(2, validation.message ?? "Invalid secret store input.");
}
return new SecretStoreCliFailure(1, formatErrorMessage(error));
}
async function runStoreAction(action: () => Promise<void>): Promise<void> {
let failure: SecretStoreCliFailure | undefined;
try {
await action();
} catch (error) {
failure = mapStoreError(error);
}
if (!failure) {
return;
}
defaultRuntime.error(danger(failure.message));
defaultRuntime.exit(failure.exitCode);
}
function renderList(entries: SecretStoreEntryMetadata[], options: OutputOptions): void {
if (options.json) {
defaultRuntime.writeJson(entries);
return;
}
if (options.plain) {
for (const entry of entries) {
defaultRuntime.writeStdout(
[entry.name, entry.kind, entry.kind === "env" ? (entry.valuePreview ?? "") : ""].join("\t"),
);
}
return;
}
if (entries.length === 0) {
defaultRuntime.log("No team secret store entries.");
return;
}
for (const entry of entries) {
const value = entry.kind === "env" ? ` = ${entry.valuePreview ?? ""}` : " (write-only)";
defaultRuntime.log(`${entry.name} [${entry.kind}]${value}`);
}
}
async function noteGatewayReload(): Promise<void> {
try {
const { readActiveGatewayLockIdentity } = await import("../infra/gateway-lock.js");
if (await readActiveGatewayLockIdentity()) {
defaultRuntime.log(
"A gateway is running. Run `openclaw secrets reload` for config-referenced values to take effect.",
);
}
} catch {
// The store write is authoritative; gateway detection is only an actionable courtesy.
}
}
async function confirmMutation(message: string, yes: boolean | undefined): Promise<void> {
if (yes) {
return;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new SecretStoreCliFailure(2, `${message} Re-run with --yes in non-interactive mode.`);
}
const { confirm, isCancel } = await import("@clack/prompts");
const approved = await confirm({ message, initialValue: false });
if (isCancel(approved) || !approved) {
throw new SecretStoreCliFailure(2, "Operation cancelled.");
}
}
export function registerSecretStoreCli(secrets: Command): void {
const store = secrets
.command("store")
.description("Manage the team-scoped SQLite secret and environment store")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/secrets", "docs.openclaw.ai/cli/secrets")}\n`,
);
store
.command("list")
.description("List stored names and non-secret metadata")
.option("--scope <team>", "Store scope", "team")
.option("--json", "Output JSON", false)
.option("--plain", "Output tab-separated rows", false)
.action((options: OutputOptions) =>
runStoreAction(async () => {
assertOutputMode(options);
const scope = teamScope(options.scope);
const { listSecretStoreEntries } = await import("../secrets/store/secret-store.js");
renderList(listSecretStoreEntries({ scope }), options);
}),
);
store
.command("set <NAME>")
.description("Create or update one store entry")
.option("--value <value>", "Literal value (env kind only)")
.option("--value-file <path>", "Read value from a file; use - for stdin")
.option("--kind <secret|env>", "Entry kind (defaults from NAME)")
.option("--scope <team>", "Store scope", "team")
.option("--dry-run", "Validate without writing", false)
.action((name: string, options: SetOptions) =>
runStoreAction(async () => {
assertStoreName(name);
const scope = teamScope(options.scope);
const kind = storeKind(options.kind, name);
if (options.value !== undefined && options.valueFile !== undefined) {
throw new SecretStoreCliFailure(2, "Use only one of --value or --value-file.");
}
// Secret argv values leak through shell history and process listings.
if (kind === "secret" && options.value !== undefined) {
throw new SecretStoreCliFailure(
2,
"--value is refused for secret entries. Use a stdin pipe, --value-file, or the interactive no-echo prompt.",
);
}
const value =
options.value !== undefined
? options.value
: await (
await import("./secrets-store-input.js")
).readSecretStoreInput({
valueFile: options.valueFile,
});
const storeModule = await import("../secrets/store/secret-store.js");
if (Buffer.byteLength(value, "utf8") > storeModule.SECRET_STORE_VALUE_MAX_BYTES) {
throw new SecretStoreCliFailure(
2,
`Value exceeds ${storeModule.SECRET_STORE_VALUE_MAX_BYTES} UTF-8 bytes.`,
);
}
if (options.dryRun) {
defaultRuntime.log(`Would ${kind === "secret" ? "write" : "set"} ${name} (${kind}).`);
return;
}
storeModule.writeSecretStoreEntry({ scope, name, value, kind, updatedBy: "cli" });
storeModule.purgeExpiredSecretStoreEntries();
defaultRuntime.log(`Stored ${name} (${kind}).`);
await noteGatewayReload();
}),
);
store
.command("get <NAME>")
.description("Read an env-kind value; secret-kind values are write-only")
.option("--scope <team>", "Store scope", "team")
.option("--json", "Output JSON", false)
.option("--plain", "Output only the env value", false)
.action((name: string, options: OutputOptions) =>
runStoreAction(async () => {
assertOutputMode(options);
assertStoreName(name);
const scope = teamScope(options.scope);
const { listSecretStoreEntries, readSecretStoreValue } =
await import("../secrets/store/secret-store.js");
const metadata = listSecretStoreEntries({ scope }).find((entry) => entry.name === name);
if (!metadata) {
throw new SecretStoreCliFailure(3, `Secret store entry "${name}" was not found.`);
}
if (metadata.kind === "secret") {
throw new SecretStoreCliFailure(
2,
`Secret store entry "${name}" is write-only by design. Reference it from config with a store SecretRef.`,
);
}
const result = readSecretStoreValue({ scope, name });
if (!result.ok) {
throw new SecretStoreCliFailure(
result.error.code === "SECRET_STORE_NOT_FOUND" ? 3 : 1,
result.error.message,
);
}
if (options.json) {
defaultRuntime.writeJson({ name, kind: metadata.kind, value: result.value });
} else if (options.plain) {
defaultRuntime.writeStdout(result.value);
} else {
defaultRuntime.log(`${name}=${result.value}`);
}
}),
);
store
.command("rm <NAME...>")
.description("Soft-delete one or more entries")
.option("--scope <team>", "Store scope", "team")
.option("--dry-run", "Show what would be removed", false)
.option("--yes", "Skip confirmation", false)
.action((names: string[], options: RemoveOptions) =>
runStoreAction(async () => {
const scope = teamScope(options.scope);
for (const name of names) {
assertStoreName(name);
}
if (options.dryRun) {
defaultRuntime.log(
`Would remove ${names.length} team store entr${names.length === 1 ? "y" : "ies"}.`,
);
return;
}
await confirmMutation(
`Remove ${names.length} team store entr${names.length === 1 ? "y" : "ies"}?`,
options.yes,
);
const { deleteSecretStoreEntry, purgeExpiredSecretStoreEntries } =
await import("../secrets/store/secret-store.js");
for (const name of names) {
deleteSecretStoreEntry({ scope, name });
}
purgeExpiredSecretStoreEntries();
defaultRuntime.log(
`Removed ${names.length} team store entr${names.length === 1 ? "y" : "ies"}.`,
);
await noteGatewayReload();
}),
);
store
.command("import")
.description("Import dotenv-formatted entries from a file or stdin")
.option("--from <file>", "Dotenv file; use - or omit for stdin")
.option("--kind <secret|env>", "Override the detected kind for all entries")
.option("--scope <team>", "Store scope", "team")
.option("--dry-run", "Validate without writing", false)
.option("--yes", "Skip confirmation", false)
.action((options: ImportOptions) =>
runStoreAction(async () => {
const scope = teamScope(options.scope);
if (!options.from && process.stdin.isTTY) {
throw new SecretStoreCliFailure(2, "Import requires --from <file> or piped stdin.");
}
const values = await (
await import("./secrets-store-input.js")
).readSecretStoreImport(options.from);
const entries = Object.entries(values);
if (entries.length === 0) {
throw new SecretStoreCliFailure(2, "Import input contains no dotenv assignments.");
}
const normalized = entries.map(([name, value]) => {
assertStoreName(name);
return { name, value, kind: storeKind(options.kind, name) };
});
const storeModule = await import("../secrets/store/secret-store.js");
for (const entry of normalized) {
if (Buffer.byteLength(entry.value, "utf8") > storeModule.SECRET_STORE_VALUE_MAX_BYTES) {
throw new SecretStoreCliFailure(
2,
`${entry.name} exceeds ${storeModule.SECRET_STORE_VALUE_MAX_BYTES} UTF-8 bytes.`,
);
}
}
if (options.dryRun) {
defaultRuntime.log(`Would import ${normalized.length} team store entries.`);
return;
}
await confirmMutation(`Import ${normalized.length} team store entries?`, options.yes);
for (const entry of normalized) {
storeModule.writeSecretStoreEntry({ scope, ...entry, updatedBy: "cli" });
}
storeModule.purgeExpiredSecretStoreEntries();
defaultRuntime.log(`Imported ${normalized.length} team store entries.`);
await noteGatewayReload();
}),
);
}
+70
View File
@@ -0,0 +1,70 @@
import fs from "node:fs/promises";
import { password } from "@clack/prompts";
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
import { parse as parseDotEnv } from "dotenv";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
import { SECRET_STORE_VALUE_MAX_BYTES } from "../secrets/store/secret-store.js";
const SECRET_STORE_IMPORT_MAX_BYTES = 16 * 1024 * 1024;
function stripOneTerminalNewline(value: string): string {
return value.replace(/\r?\n$/u, "");
}
async function readBoundedStdin(maxBytes: number): Promise<string> {
const bytes = await readByteStreamWithLimit(process.stdin, {
maxBytes,
onOverflow: ({ maxBytes: limit }) => new Error(`Stdin input exceeds ${limit} bytes.`),
});
return bytes.toString("utf8");
}
async function readBoundedFile(pathname: string, maxBytes: number): Promise<string> {
const file = await fs.open(pathname, "r");
try {
const stat = await file.stat();
if (!stat.isFile()) {
throw new Error(`Input path is not a regular file: ${pathname}`);
}
if (stat.size > maxBytes) {
throw new Error(`Input file exceeds ${maxBytes} bytes: ${pathname}`);
}
return (await readFileDescriptorBounded(file.fd, maxBytes)).toString("utf8");
} finally {
await file.close();
}
}
export async function readSecretStoreInput(params: { valueFile?: string }): Promise<string> {
if (params.valueFile && params.valueFile !== "-") {
return await readBoundedFile(params.valueFile, SECRET_STORE_VALUE_MAX_BYTES);
}
if (params.valueFile === "-" || !process.stdin.isTTY) {
return stripOneTerminalNewline(await readBoundedStdin(SECRET_STORE_VALUE_MAX_BYTES));
}
const value = await password({
message: "Secret value",
// Empty masking prevents shoulder-surfing length disclosure as well as terminal echo.
mask: "",
validate: (candidate) =>
Buffer.byteLength(candidate ?? "", "utf8") <= SECRET_STORE_VALUE_MAX_BYTES
? undefined
: `Value exceeds ${SECRET_STORE_VALUE_MAX_BYTES} UTF-8 bytes.`,
});
if (typeof value === "symbol") {
throw new Error("Secret input cancelled.");
}
return value;
}
export function parseSecretStoreDotEnv(raw: string | Buffer): Record<string, string> {
return parseDotEnv(raw);
}
export async function readSecretStoreImport(from?: string): Promise<Record<string, string>> {
const raw =
from && from !== "-"
? await readBoundedFile(from, SECRET_STORE_IMPORT_MAX_BYTES)
: await readBoundedStdin(SECRET_STORE_IMPORT_MAX_BYTES);
return parseSecretStoreDotEnv(raw);
}
File diff suppressed because one or more lines are too long
+13
View File
@@ -37,7 +37,9 @@ describe("config secret refs schema", () => {
command: "/usr/local/bin/openclaw-secret-resolver", command: "/usr/local/bin/openclaw-secret-resolver",
args: ["resolve"], args: ["resolve"],
}, },
store: { source: "store" },
}, },
defaults: { store: "store" },
}, },
models: { models: {
providers: { providers: {
@@ -46,6 +48,11 @@ describe("config secret refs schema", () => {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [{ id: "gpt-5", name: "gpt-5" }], models: [{ id: "gpt-5", name: "gpt-5" }],
}, },
stored: {
baseUrl: "https://stored.example.test/v1",
apiKey: { source: "store", provider: "store", id: "STORED_API_KEY" },
models: [{ id: "fixture", name: "fixture" }],
},
}, },
}, },
}); });
@@ -53,6 +60,12 @@ describe("config secret refs schema", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
}); });
it("rejects store refs outside the env-name grammar", () => {
expect(
validateOpenAiApiKeyRef({ source: "store", provider: "default", id: "lowercase" }).ok,
).toBe(false);
});
it("accepts openai-chatgpt-responses as a model api value", () => { it("accepts openai-chatgpt-responses as a model api value", () => {
const result = validateConfigObjectRaw({ const result = validateConfigObjectRaw({
models: { models: {
+28
View File
@@ -89,6 +89,27 @@ function collectMetadataOnlyCompositionBranches(
return hits; return hits;
} }
function collectSchemaConsts(
schema: TestJsonSchema | undefined,
values = new Set<unknown>(),
): Set<unknown> {
if (!schema) {
return values;
}
if (schema.const !== undefined) {
values.add(schema.const);
}
for (const child of [
...(schema.oneOf ?? []),
...(schema.anyOf ?? []),
...(schema.allOf ?? []),
...Object.values(schema.properties ?? {}),
]) {
collectSchemaConsts(child, values);
}
return values;
}
describe("base config schema", () => { describe("base config schema", () => {
it("is deterministic for a fixed generatedAt timestamp", () => { it("is deterministic for a fixed generatedAt timestamp", () => {
expect( expect(
@@ -172,6 +193,13 @@ describe("base config schema", () => {
expect(uiHints).toHaveProperty("agents.defaults.voiceModel.fallbacks"); expect(uiHints).toHaveProperty("agents.defaults.voiceModel.fallbacks");
}); });
it("publishes all four SecretRef sources in generated JSON schema", () => {
const apiKeySchema = schemaAt(BASE_SCHEMA, ["models", "providers", "*", "apiKey"]);
expect(
[...collectSchemaConsts(apiKeySchema)].filter((value) => typeof value === "string"),
).toEqual(expect.arrayContaining(["env", "file", "exec", "store"]));
});
it("publishes accepted input shapes for transform-backed config fields", () => { it("publishes accepted input shapes for transform-backed config fields", () => {
for (const path of [ for (const path of [
["agents", "defaults", "sandbox", "docker", "setupCommand"], ["agents", "defaults", "sandbox", "docker", "setupCommand"],
+13 -1
View File
@@ -1,6 +1,10 @@
// Verifies secret config type guards and normalization helpers. // Verifies secret config type guards and normalization helpers.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { collectEnvSecretRefIds, parseEnvTemplateSecretRef } from "./types.secrets.js"; import {
coerceSecretRef,
collectEnvSecretRefIds,
parseEnvTemplateSecretRef,
} from "./types.secrets.js";
describe("parseEnvTemplateSecretRef", () => { describe("parseEnvTemplateSecretRef", () => {
it("parses ${VAR} template syntax", () => { it("parses ${VAR} template syntax", () => {
@@ -56,3 +60,11 @@ describe("collectEnvSecretRefIds", () => {
).toEqual(new Set(["OPENAI_API_KEY", "LEGACY_API_KEY", "DISCORD_BOT_TOKEN"])); ).toEqual(new Set(["OPENAI_API_KEY", "LEGACY_API_KEY", "DISCORD_BOT_TOKEN"]));
}); });
}); });
describe("store SecretRef coercion", () => {
it("applies the store-specific default provider to providerless refs", () => {
expect(
coerceSecretRef({ source: "store", id: "STORED_API_KEY" }, { store: "teamstore" }),
).toEqual({ source: "store", provider: "teamstore", id: "STORED_API_KEY" });
});
});
+21 -11
View File
@@ -1,9 +1,9 @@
import { expectDefined } from "@openclaw/normalization-core"; import { expectDefined } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
// Defines secret reference and resolution configuration types. // Defines secret reference and resolution configuration types.
import { isRecord } from "../utils.js";
/** Supported secret reference backends in config. */ /** Supported secret reference backends in config. */
export type SecretRefSource = "env" | "file" | "exec"; // pragma: allowlist secret export type SecretRefSource = "env" | "file" | "exec" | "store"; // pragma: allowlist secret
/** /**
* Stable identifier for a secret in a configured source. * Stable identifier for a secret in a configured source.
@@ -11,6 +11,7 @@ export type SecretRefSource = "env" | "file" | "exec"; // pragma: allowlist secr
* - env source: provider "default", id "OPENAI_API_KEY" * - env source: provider "default", id "OPENAI_API_KEY"
* - file source: provider "mounted-json", id "/providers/openai/apiKey" * - file source: provider "mounted-json", id "/providers/openai/apiKey"
* - exec source: provider "vault", id "openai/api-key" * - exec source: provider "vault", id "openai/api-key"
* - store source: provider "default", id "OPENAI_API_KEY"
*/ */
export type SecretRef = { export type SecretRef = {
source: SecretRefSource; source: SecretRefSource;
@@ -44,6 +45,8 @@ type SecretDefaults = {
file?: string; file?: string;
/** Default provider alias for exec SecretRefs. */ /** Default provider alias for exec SecretRefs. */
exec?: string; exec?: string;
/** Default provider alias for shared-store SecretRefs. */
store?: string;
}; };
/** Return whether an env SecretRef id is a supported uppercase environment variable name. */ /** Return whether an env SecretRef id is a supported uppercase environment variable name. */
@@ -60,7 +63,10 @@ export function isSecretRef(value: unknown): value is SecretRef {
return false; return false;
} }
return ( return (
(value.source === "env" || value.source === "file" || value.source === "exec") && (value.source === "env" ||
value.source === "file" ||
value.source === "exec" ||
value.source === "store") &&
typeof value.provider === "string" && typeof value.provider === "string" &&
value.provider.trim().length > 0 && value.provider.trim().length > 0 &&
typeof value.id === "string" && typeof value.id === "string" &&
@@ -75,7 +81,10 @@ function isLegacySecretRefWithoutProvider(
return false; return false;
} }
return ( return (
(value.source === "env" || value.source === "file" || value.source === "exec") && (value.source === "env" ||
value.source === "file" ||
value.source === "exec" ||
value.source === "store") &&
typeof value.id === "string" && typeof value.id === "string" &&
value.id.trim().length > 0 && value.id.trim().length > 0 &&
value.provider === undefined value.provider === undefined
@@ -171,12 +180,7 @@ export function coerceSecretRef(value: unknown, defaults?: SecretDefaults): Secr
return value; return value;
} }
if (isLegacySecretRefWithoutProvider(value)) { if (isLegacySecretRefWithoutProvider(value)) {
const provider = const provider = defaults?.[value.source] ?? DEFAULT_SECRET_PROVIDER_ALIAS;
value.source === "env"
? (defaults?.env ?? DEFAULT_SECRET_PROVIDER_ALIAS)
: value.source === "file"
? (defaults?.file ?? DEFAULT_SECRET_PROVIDER_ALIAS)
: (defaults?.exec ?? DEFAULT_SECRET_PROVIDER_ALIAS);
return { return {
source: value.source, source: value.source,
provider, provider,
@@ -369,10 +373,15 @@ export type ExecSecretProviderConfig =
| ManualExecSecretProviderConfig | ManualExecSecretProviderConfig
| PluginIntegrationSecretProviderConfig; | PluginIntegrationSecretProviderConfig;
export type StoreSecretProviderConfig = {
source: "store";
};
export type SecretProviderConfig = export type SecretProviderConfig =
| EnvSecretProviderConfig | EnvSecretProviderConfig
| FileSecretProviderConfig | FileSecretProviderConfig
| ExecSecretProviderConfig; | ExecSecretProviderConfig
| StoreSecretProviderConfig;
export type SecretsConfig = { export type SecretsConfig = {
providers?: Record<string, SecretProviderConfig>; providers?: Record<string, SecretProviderConfig>;
@@ -380,5 +389,6 @@ export type SecretsConfig = {
env?: string; env?: string;
file?: string; file?: string;
exec?: string; exec?: string;
store?: string;
}; };
}; };
+29 -6
View File
@@ -8,14 +8,14 @@ import {
formatExecSecretRefIdValidationMessage, formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId, isValidExecSecretRefId,
isValidFileSecretRefId, isValidFileSecretRefId,
SECRET_PROVIDER_ALIAS_PATTERN,
} from "../secrets/ref-contract.js"; } from "../secrets/ref-contract.js";
import type { ModelCompatConfig } from "./types.models.js"; import type { ModelCompatConfig } from "./types.models.js";
import { MODEL_APIS, MODEL_THINKING_FORMATS } from "./types.models.js"; import { MODEL_APIS, MODEL_THINKING_FORMATS } from "./types.models.js";
import { ENV_SECRET_REF_ID_RE } from "./types.secrets.js";
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js"; import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
import { sensitive } from "./zod-schema.sensitive.js"; import { sensitive } from "./zod-schema.sensitive.js";
const ENV_SECRET_REF_ID_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
@@ -41,7 +41,7 @@ const EnvSecretRefSchema = z
id: z id: z
.string() .string()
.regex( .regex(
ENV_SECRET_REF_ID_PATTERN, ENV_SECRET_REF_ID_RE,
'Env secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").', 'Env secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").',
), ),
}) })
@@ -78,11 +78,30 @@ const ExecSecretRefSchema = z
}) })
.strict(); .strict();
const StoreSecretRefSchema = z
.object({
source: z.literal("store"),
provider: z
.string()
.regex(
SECRET_PROVIDER_ALIAS_PATTERN,
'Secret reference provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").',
),
id: z
.string()
.regex(
ENV_SECRET_REF_ID_RE,
'Store secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").',
),
})
.strict();
/** Config-level secret reference schema shared by model/provider/plugin credential fields. */ /** Config-level secret reference schema shared by model/provider/plugin credential fields. */
export const SecretRefSchema = z.discriminatedUnion("source", [ export const SecretRefSchema = z.discriminatedUnion("source", [
EnvSecretRefSchema, EnvSecretRefSchema,
FileSecretRefSchema, FileSecretRefSchema,
ExecSecretRefSchema, ExecSecretRefSchema,
StoreSecretRefSchema,
]); ]);
/** Accepts either legacy inline secret strings or structured secret references. */ /** Accepts either legacy inline secret strings or structured secret references. */
@@ -101,7 +120,7 @@ export const SsrFPolicyConfigSchema = z
const SecretsEnvProviderSchema = z const SecretsEnvProviderSchema = z
.object({ .object({
source: z.literal("env"), source: z.literal("env"),
allowlist: z.array(z.string().regex(ENV_SECRET_REF_ID_PATTERN)).max(256).optional(), allowlist: z.array(z.string().regex(ENV_SECRET_REF_ID_RE)).max(256).optional(),
}) })
.strict(); .strict();
@@ -142,7 +161,7 @@ const SecretsManualExecProviderSchema = z
.optional(), .optional(),
jsonOnly: z.boolean().optional(), jsonOnly: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
passEnv: z.array(z.string().regex(ENV_SECRET_REF_ID_PATTERN)).max(128).optional(), passEnv: z.array(z.string().regex(ENV_SECRET_REF_ID_RE)).max(128).optional(),
trustedDirs: z trustedDirs: z
.array( .array(
z z
@@ -172,11 +191,14 @@ const SecretsExecProviderSchema = z.union([
SecretsPluginIntegrationExecProviderSchema, SecretsPluginIntegrationExecProviderSchema,
]); ]);
/** Schema for one configured env/file/exec secret provider entry. */ const SecretsStoreProviderSchema = z.object({ source: z.literal("store") }).strict();
/** Schema for one configured env/file/exec/store secret provider entry. */
export const SecretProviderSchema = z.union([ export const SecretProviderSchema = z.union([
SecretsEnvProviderSchema, SecretsEnvProviderSchema,
SecretsFileProviderSchema, SecretsFileProviderSchema,
SecretsExecProviderSchema, SecretsExecProviderSchema,
SecretsStoreProviderSchema,
]); ]);
/** Schema for the top-level `secrets` config block. */ /** Schema for the top-level `secrets` config block. */
@@ -193,6 +215,7 @@ export const SecretsConfigSchema = z
env: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(), env: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(),
file: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(), file: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(),
exec: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(), exec: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(),
store: z.string().regex(SECRET_PROVIDER_ALIAS_PATTERN).optional(),
}) })
.strict() .strict()
.optional(), .optional(),
+3 -1
View File
@@ -210,7 +210,9 @@ export async function deliverOutboundPayloadsWithQueueCleanup(
}, },
onPlatformSendDispatch: async () => { onPlatformSendDispatch: async () => {
params.abortSignal?.throwIfAborted(); params.abortSignal?.throwIfAborted();
if (platformQueueId && queuedPreSendState !== "acked") { // Once any payload returns an identity, unknown-after-send protects the whole batch.
// A later payload dispatch must not regress that durable evidence to attempt-started.
if (platformQueueId && queuedPreSendState !== "acked" && queuedPostSendState === undefined) {
try { try {
if (producerClaimId) { if (producerClaimId) {
await markDeliveryPlatformSendDispatched( await markDeliveryPlatformSendDispatched(
+13 -1
View File
@@ -10,7 +10,7 @@ import {
} from "../secrets/ref-contract.js"; } from "../secrets/ref-contract.js";
/** /**
* Returns the shared secret-input schema for plaintext values and env/file/exec refs. * Returns the shared secret-input schema for plaintext values and env/file/exec/store refs.
* Reusing this singleton preserves sensitive-path registration for config redaction. * Reusing this singleton preserves sensitive-path registration for config redaction.
*/ */
export function buildSecretInputSchema() { export function buildSecretInputSchema() {
@@ -48,6 +48,18 @@ const secretInputSchema = z
), ),
}) })
.strict(), .strict(),
z
.object({
source: z.literal("store"),
provider: providerSchema,
id: z
.string()
.regex(
ENV_SECRET_REF_ID_RE,
'Store secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (example: "OPENAI_API_KEY").',
),
})
.strict(),
z z
.object({ .object({
source: z.literal("file"), source: z.literal("file"),
+9
View File
@@ -51,6 +51,9 @@ describe("plugin-sdk secret input schema", () => {
schema.safeParse({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" }) schema.safeParse({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" })
.success, .success,
).toBe(true); ).toBe(true);
expect(
schema.safeParse({ source: "store", provider: "default", id: "STORED_API_KEY" }).success,
).toBe(true);
for (const id of VALID_EXEC_SECRET_REF_IDS) { for (const id of VALID_EXEC_SECRET_REF_IDS) {
expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(true); expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(true);
} }
@@ -61,4 +64,10 @@ describe("plugin-sdk secret input schema", () => {
expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(false); expect(schema.safeParse({ source: "exec", provider: "vault", id }).success, id).toBe(false);
} }
}); });
it("rejects store refs outside the env-name grammar", () => {
expect(
schema.safeParse({ source: "store", provider: "default", id: "lowercase" }).success,
).toBe(false);
});
}); });
+76 -13
View File
@@ -1,4 +1,4 @@
/** Resolves provider auth secret refs from env, file, and exec-backed secret providers. */ /** Resolves provider auth secret refs from env, file, exec, and store-backed providers. */
import { import {
normalizeOptionalString, normalizeOptionalString,
normalizeStringifiedOptionalString, normalizeStringifiedOptionalString,
@@ -25,7 +25,7 @@ function loadSecretResolve() {
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/; const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret type SecretRefChoice = "env" | "store" | "provider"; // pragma: allowlist secret
/** Copy overrides used while prompting for provider secret-ref setup. */ /** Copy overrides used while prompting for provider secret-ref setup. */
export type SecretRefSetupPromptCopy = { export type SecretRefSetupPromptCopy = {
@@ -36,7 +36,11 @@ export type SecretRefSetupPromptCopy = {
envVarMissingError?: (envVar: string) => string; envVarMissingError?: (envVar: string) => string;
noProvidersMessage?: string; noProvidersMessage?: string;
envValidatedMessage?: (envVar: string) => string; envValidatedMessage?: (envVar: string) => string;
providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string; providerValidatedMessage?: (
provider: string,
id: string,
source: "file" | "exec" | "store",
) => string;
}; };
/** Extracts a trailing env var name from a human-facing secret source label. */ /** Extracts a trailing env var name from a human-facing secret source label. */
@@ -162,12 +166,13 @@ async function promptProviderSecretRefForSetup(params: {
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
}): Promise<{ ref: SecretRef; resolvedValue: string }> { }): Promise<{ ref: SecretRef; resolvedValue: string }> {
const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter( const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter(
([, provider]) => provider?.source === "file" || provider?.source === "exec", ([, provider]) =>
provider?.source === "file" || provider?.source === "exec" || provider?.source === "store",
); );
if (externalProviders.length === 0) { if (externalProviders.length === 0) {
await params.prompter.note( await params.prompter.note(
params.copy?.noProvidersMessage ?? params.copy?.noProvidersMessage ??
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.", "No file/exec/store secret providers are configured yet. Add one under secrets.providers, or select a built-in source.",
"No providers configured", "No providers configured",
); );
throw new Error("retry"); throw new Error("retry");
@@ -184,13 +189,23 @@ async function promptProviderSecretRefForSetup(params: {
options: externalProviders.map(([providerName, provider]) => ({ options: externalProviders.map(([providerName, provider]) => ({
value: providerName, value: providerName,
label: providerName, label: providerName,
hint: provider?.source === "exec" ? "Exec provider" : "File provider", hint:
provider?.source === "exec"
? "Exec provider"
: provider?.source === "store"
? "Store provider"
: "File provider",
})), })),
}); });
const providerEntry = params.config.secrets?.providers?.[selectedProvider]; const providerEntry = params.config.secrets?.providers?.[selectedProvider];
if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) { if (
!providerEntry ||
(providerEntry.source !== "file" &&
providerEntry.source !== "exec" &&
providerEntry.source !== "store")
) {
await params.prompter.note( await params.prompter.note(
`Provider "${selectedProvider}" is not a file/exec provider.`, `Provider "${selectedProvider}" is not a file/exec/store provider.`,
"Invalid provider", "Invalid provider",
); );
throw new Error("retry"); throw new Error("retry");
@@ -199,17 +214,26 @@ async function promptProviderSecretRefForSetup(params: {
const idPrompt = const idPrompt =
providerEntry.source === "file" providerEntry.source === "file"
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)" ? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
: "Secret id for the exec provider"; : providerEntry.source === "store"
? "Secret store name"
: "Secret id for the exec provider";
const idDefault = const idDefault =
providerEntry.source === "file" providerEntry.source === "file"
? providerEntry.mode === "singleValue" ? providerEntry.mode === "singleValue"
? "value" ? "value"
: params.defaultFilePointer : params.defaultFilePointer
: `${params.provider}/apiKey`; : providerEntry.source === "store"
? (resolveDefaultProviderEnvVar(params.provider, params.config) ?? "")
: `${params.provider}/apiKey`;
const idRaw = await params.prompter.text({ const idRaw = await params.prompter.text({
message: idPrompt, message: idPrompt,
initialValue: idDefault, initialValue: idDefault,
placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key", placeholder:
providerEntry.source === "file"
? "/providers/openai/apiKey"
: providerEntry.source === "store"
? "OPENAI_API_KEY"
: "openai/api-key",
validate: (value) => { validate: (value) => {
const candidate = value.trim(); const candidate = value.trim();
if (!candidate) { if (!candidate) {
@@ -232,6 +256,9 @@ async function promptProviderSecretRefForSetup(params: {
if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) { if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) {
return formatExecSecretRefIdValidationMessage(); return formatExecSecretRefIdValidationMessage();
} }
if (providerEntry.source === "store" && !isValidEnvSecretRefId(candidate)) {
return 'Use a store name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).';
}
return undefined; return undefined;
}, },
}); });
@@ -290,14 +317,20 @@ export async function promptSecretRefForSetup(params: {
label: "Environment variable", label: "Environment variable",
hint: "Reference a variable from your runtime environment", hint: "Reference a variable from your runtime environment",
}, },
{
value: "store",
label: "OpenClaw secret store",
hint: "Reference a team-scoped value in the shared state database",
},
{ {
value: "provider", value: "provider",
label: "Configured secret provider", label: "Configured secret provider",
hint: "Use a configured file or exec secret provider", hint: "Use a configured file, exec, or store secret provider",
}, },
], ],
}); });
const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env"; const source: SecretRefChoice =
sourceRaw === "provider" ? "provider" : sourceRaw === "store" ? "store" : "env";
sourceChoice = source; sourceChoice = source;
if (source === "env") { if (source === "env") {
@@ -311,6 +344,36 @@ export async function promptSecretRefForSetup(params: {
}); });
} }
if (source === "store") {
const idRaw = await params.prompter.text({
message: "Secret store name",
initialValue: defaultEnvVar || undefined,
placeholder: "OPENAI_API_KEY",
validate: (value) =>
isValidEnvSecretRefId(value.trim())
? undefined
: 'Use a store name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).',
});
const id = normalizeStringifiedOptionalString(idRaw) ?? defaultEnvVar;
const ref: SecretRef = {
source: "store",
provider: resolveDefaultSecretProviderAlias(params.config, "store", {
preferFirstProviderForSource: true,
}),
id,
};
const { resolveSecretRefString } = await loadSecretResolve();
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: params.env ?? process.env,
});
await params.prompter.note(
`Validated store reference ${ref.provider}:${id}. OpenClaw will store a reference, not the value.`,
"Reference validated",
);
return { ref, resolvedValue };
}
try { try {
return await promptProviderSecretRefForSetup({ return await promptProviderSecretRefForSetup({
provider: params.provider, provider: params.provider,
+1 -1
View File
@@ -54,7 +54,7 @@ export type ProviderAuthContext = {
* Onboarding secret persistence preference. * Onboarding secret persistence preference.
* *
* Interactive wizard flows set this when the caller explicitly requested * Interactive wizard flows set this when the caller explicitly requested
* plaintext or env/file/exec ref storage. Ad-hoc `models auth login` flows * plaintext or env/file/exec/store ref storage. Ad-hoc `models auth login` flows
* usually leave it undefined. * usually leave it undefined.
*/ */
secretInputMode?: SecretInputMode; secretInputMode?: SecretInputMode;
+59
View File
@@ -0,0 +1,59 @@
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { listSecretStoreEntries, readSecretStoreValue } from "./store/secret-store.js";
export type PlaintextAssignment = {
file: string;
path: string;
value: string;
};
export function findSecretStorePlaintextResidueFindings(params: {
assignments: PlaintextAssignment[];
database: OpenClawStateDatabaseOptions;
}): Array<{
code: "STORE_PLAINTEXT_RESIDUE";
severity: "warn";
file: string;
jsonPath: string;
message: string;
}> {
const entries = listSecretStoreEntries({
scope: { kind: "team" },
database: params.database,
});
if (entries.length === 0 || params.assignments.length === 0) {
return [];
}
const namesByValue = new Map<string, string[]>();
for (const entry of entries) {
const result = readSecretStoreValue({
scope: { kind: "team" },
name: entry.name,
database: params.database,
});
if (!result.ok) {
if (result.error.code === "SECRET_STORE_NOT_FOUND") {
continue;
}
if (result.error.code === "SECRET_STORE_INVALID_NAME") {
throw new Error(result.error.message);
}
throw new Error(result.error.message, { cause: result.error.cause });
}
const names = namesByValue.get(result.value);
if (names) {
names.push(entry.name);
} else {
namesByValue.set(result.value, [entry.name]);
}
}
return params.assignments.flatMap((assignment) =>
(namesByValue.get(assignment.value) ?? []).map((name) => ({
code: "STORE_PLAINTEXT_RESIDUE" as const,
severity: "warn" as const,
file: assignment.file,
jsonPath: assignment.path,
message: `${assignment.path} duplicates team secret store entry "${name}"; replace the plaintext with a store SecretRef.`,
})),
);
}
+49
View File
@@ -9,7 +9,9 @@ import {
writePersistedAuthProfileStoreRaw, writePersistedAuthProfileStoreRaw,
} from "../agents/auth-profiles/sqlite.js"; } from "../agents/auth-profiles/sqlite.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { runSecretsAudit } from "./audit.js"; import { runSecretsAudit } from "./audit.js";
import { writeSecretStoreEntry } from "./store/secret-store.js";
type AuditFixture = { type AuditFixture = {
rootDir: string; rootDir: string;
@@ -221,6 +223,7 @@ describe("secrets audit", () => {
await runSecretsAudit({ env: warmFixture.env }); await runSecretsAudit({ env: warmFixture.env });
} finally { } finally {
closeOpenClawAgentDatabasesForTest(); closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
await fs.rm(warmFixture.rootDir, { recursive: true, force: true }); await fs.rm(warmFixture.rootDir, { recursive: true, force: true });
} }
}); });
@@ -266,6 +269,7 @@ describe("secrets audit", () => {
afterEach(async () => { afterEach(async () => {
closeOpenClawAgentDatabasesForTest(); closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
await fs.rm(fixture.rootDir, { recursive: true, force: true }); await fs.rm(fixture.rootDir, { recursive: true, force: true });
}); });
@@ -279,6 +283,51 @@ describe("secrets audit", () => {
expectFindingCode(report, "PLAINTEXT_FOUND"); expectFindingCode(report, "PLAINTEXT_FOUND");
}); });
it("reports plaintext that duplicates the store while resolving store refs", async () => {
writeSecretStoreEntry({
scope: { kind: "team" },
name: "STORED_API_KEY",
value: "shared-store-value",
kind: "secret",
updatedBy: "test",
database: { env: fixture.env },
});
await writeJsonFile(fixture.configPath, {
models: {
providers: {
plaintext: {
baseUrl: "https://plaintext.example.test/v1",
api: "openai-completions",
apiKey: "shared-store-value",
models: [{ id: "fixture", name: "fixture" }],
},
referenced: {
baseUrl: "https://referenced.example.test/v1",
api: "openai-completions",
apiKey: { source: "store", provider: "default", id: "STORED_API_KEY" },
models: [{ id: "fixture", name: "fixture" }],
},
},
},
});
const report = await runSecretsAudit({ env: fixture.env });
expect(report.summary.storeResidueCount).toBe(1);
expect(report.findings.find((entry) => entry.code === "STORE_PLAINTEXT_RESIDUE")).toMatchObject(
{
jsonPath: "models.providers.plaintext.apiKey",
message: expect.stringContaining("STORED_API_KEY"),
},
);
expect(
report.findings.some(
(entry) =>
entry.code === "REF_UNRESOLVED" &&
entry.jsonPath === "models.providers.referenced.apiKey",
),
).toBe(false);
});
it("does not inspect or mutate legacy auth.json during audit", async () => { it("does not inspect or mutate legacy auth.json during audit", async () => {
await writeJsonFile(fixture.authJsonPath, { await writeJsonFile(fixture.authJsonPath, {
openai: { openai: {
+38 -15
View File
@@ -20,6 +20,8 @@ import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.j
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js"; import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { findSecretStorePlaintextResidueFindings } from "./audit-store.js";
import type { PlaintextAssignment } from "./audit-store.js";
import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js"; import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js";
import { createSecretsConfigIO } from "./config-io.js"; import { createSecretsConfigIO } from "./config-io.js";
import { getSkippedExecRefStaticError, selectRefsForExecPolicy } from "./exec-resolution-policy.js"; import { getSkippedExecRefStaticError, selectRefsForExecPolicy } from "./exec-resolution-policy.js";
@@ -47,7 +49,12 @@ import {
import { discoverConfigSecretTargets } from "./target-registry.js"; import { discoverConfigSecretTargets } from "./target-registry.js";
/** Stable finding codes emitted by `openclaw secrets audit`. */ /** Stable finding codes emitted by `openclaw secrets audit`. */
type SecretsAuditCode = "PLAINTEXT_FOUND" | "REF_UNRESOLVED" | "REF_SHADOWED" | "LEGACY_RESIDUE"; type SecretsAuditCode =
| "PLAINTEXT_FOUND"
| "REF_UNRESOLVED"
| "REF_SHADOWED"
| "STORE_PLAINTEXT_RESIDUE"
| "LEGACY_RESIDUE";
/** Audit severity used for CLI output and check-mode exit behavior. */ /** Audit severity used for CLI output and check-mode exit behavior. */
type SecretsAuditSeverity = "info" | "warn" | "error"; // pragma: allowlist secret type SecretsAuditSeverity = "info" | "warn" | "error"; // pragma: allowlist secret
@@ -80,6 +87,7 @@ type SecretsAuditReport = {
plaintextCount: number; plaintextCount: number;
unresolvedRefCount: number; unresolvedRefCount: number;
shadowedRefCount: number; shadowedRefCount: number;
storeResidueCount: number;
legacyResidueCount: number; legacyResidueCount: number;
}; };
findings: SecretsAuditFinding[]; findings: SecretsAuditFinding[];
@@ -109,6 +117,7 @@ type AuditCollector = {
refAssignments: RefAssignment[]; refAssignments: RefAssignment[];
configProviderRefPaths: Map<string, string[]>; configProviderRefPaths: Map<string, string[]>;
authProviderState: Map<string, ProviderAuthState>; authProviderState: Map<string, ProviderAuthState>;
configPlaintextAssignments: PlaintextAssignment[];
filesScanned: Set<string>; filesScanned: Set<string>;
}; };
@@ -196,6 +205,24 @@ function collectConfigSecrets(params: {
refValue: target.refValue, refValue: target.refValue,
defaults, defaults,
}); });
const hasPlaintext = hasConfiguredPlaintextSecretValue(
target.value,
target.entry.expectedResolvedValue,
);
const isNonSecretHeader =
target.entry.id === "models.providers.*.headers.*" &&
!isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "");
const isModelMarker =
target.entry.id === "models.providers.*.apiKey" &&
typeof target.value === "string" &&
isNonSecretApiKeyMarker(target.value);
if (hasPlaintext && !isNonSecretHeader && !isModelMarker && typeof target.value === "string") {
params.collector.configPlaintextAssignments.push({
file: params.configPath,
path: target.path,
value: target.value,
});
}
if (ref) { if (ref) {
params.collector.refAssignments.push({ params.collector.refAssignments.push({
file: params.configPath, file: params.configPath,
@@ -210,21 +237,10 @@ function collectConfigSecrets(params: {
continue; continue;
} }
const hasPlaintext = hasConfiguredPlaintextSecretValue( if (isNonSecretHeader) {
target.value,
target.entry.expectedResolvedValue,
);
if (
target.entry.id === "models.providers.*.headers.*" &&
!isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "")
) {
continue; continue;
} }
if ( if (isModelMarker) {
target.entry.id === "models.providers.*.apiKey" &&
typeof target.value === "string" &&
isNonSecretApiKeyMarker(target.value)
) {
continue; continue;
} }
if (!hasPlaintext) { if (!hasPlaintext) {
@@ -612,11 +628,11 @@ function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport[
plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length, plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length,
unresolvedRefCount: findings.filter((entry) => entry.code === "REF_UNRESOLVED").length, unresolvedRefCount: findings.filter((entry) => entry.code === "REF_UNRESOLVED").length,
shadowedRefCount: findings.filter((entry) => entry.code === "REF_SHADOWED").length, shadowedRefCount: findings.filter((entry) => entry.code === "REF_SHADOWED").length,
storeResidueCount: findings.filter((entry) => entry.code === "STORE_PLAINTEXT_RESIDUE").length,
legacyResidueCount: findings.filter((entry) => entry.code === "LEGACY_RESIDUE").length, legacyResidueCount: findings.filter((entry) => entry.code === "LEGACY_RESIDUE").length,
}; };
} }
/** Runs local storage/config audit and returns a structured report. */
/** Runs a secrets audit over config/auth stores and returns structured findings. */ /** Runs a secrets audit over config/auth stores and returns structured findings. */
export async function runSecretsAudit( export async function runSecretsAudit(
params: { params: {
@@ -636,6 +652,7 @@ export async function runSecretsAudit(
refAssignments: [], refAssignments: [],
configProviderRefPaths: new Map(), configProviderRefPaths: new Map(),
authProviderState: new Map(), authProviderState: new Map(),
configPlaintextAssignments: [],
filesScanned: new Set([configPath]), filesScanned: new Set([configPath]),
}; };
@@ -679,6 +696,12 @@ export async function runSecretsAudit(
resolvabilityComplete: unresolvedRefResult.skippedExecRefs === 0, resolvabilityComplete: unresolvedRefResult.skippedExecRefs === 0,
}; };
collectShadowingFindings(collector); collectShadowingFindings(collector);
collector.findings.push(
...findSecretStorePlaintextResidueFindings({
assignments: collector.configPlaintextAssignments,
database: { env },
}),
);
} else { } else {
addFinding(collector, { addFinding(collector, {
code: "REF_UNRESOLVED", code: "REF_UNRESOLVED",
+26 -11
View File
@@ -12,11 +12,12 @@ import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { import {
ManualExecSecretProviderConfig, isValidEnvSecretRefId,
SecretProviderConfig, type ManualExecSecretProviderConfig,
SecretRef, type SecretProviderConfig,
SecretRefSource, type SecretRef,
type SecretRefSource,
} from "../config/types.secrets.js"; } from "../config/types.secrets.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
@@ -54,7 +55,6 @@ type SecretsConfigureResult = {
preflight: SecretsApplyResult; preflight: SecretsApplyResult;
}; };
const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
@@ -131,11 +131,15 @@ function removeSecretProvider(config: OpenClawConfig, providerAlias: string): bo
if (defaults?.exec === providerAlias) { if (defaults?.exec === providerAlias) {
delete defaults.exec; delete defaults.exec;
} }
if (defaults?.store === providerAlias) {
delete defaults.store;
}
if ( if (
defaults && defaults &&
defaults.env === undefined && defaults.env === undefined &&
defaults.file === undefined && defaults.file === undefined &&
defaults.exec === undefined defaults.exec === undefined &&
defaults.store === undefined
) { ) {
delete config.secrets?.defaults; delete config.secrets?.defaults;
} }
@@ -150,6 +154,9 @@ function providerHint(provider: SecretProviderConfig): string {
if (provider.source === "file") { if (provider.source === "file") {
return `file (${provider.mode ?? "json"})`; return `file (${provider.mode ?? "json"})`;
} }
if (provider.source === "store") {
return "store";
}
if ("pluginIntegration" in provider) { if ("pluginIntegration" in provider) {
const { pluginId, integrationId } = provider.pluginIntegration; const { pluginId, integrationId } = provider.pluginIntegration;
return `exec plugin (${pluginId}:${integrationId})`; return `exec plugin (${pluginId}:${integrationId})`;
@@ -188,6 +195,7 @@ function toSourceChoices(config: OpenClawConfig): Array<{ value: SecretRefSource
value: "env", value: "env",
label: "env", label: "env",
}, },
{ value: "store", label: "store" },
]; ];
if (hasSource("file")) { if (hasSource("file")) {
choices.push({ value: "file", label: "file" }); choices.push({ value: "file", label: "file" });
@@ -210,7 +218,7 @@ const AUTH_PROFILE_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
function validateEnvNameCsv(value: string): string | undefined { function validateEnvNameCsv(value: string): string | undefined {
const entries = parseCsv(value); const entries = parseCsv(value);
for (const entry of entries) { for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) { if (!isValidEnvSecretRefId(entry)) {
return `Invalid env name: ${entry}`; return `Invalid env name: ${entry}`;
} }
} }
@@ -422,6 +430,7 @@ async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRe
{ value: "env", label: "env" }, { value: "env", label: "env" },
{ value: "file", label: "file" }, { value: "file", label: "file" },
{ value: "exec", label: "exec" }, { value: "exec", label: "exec" },
{ value: "store", label: "store" },
], ],
initialValue: initial, initialValue: initial,
}), }),
@@ -629,6 +638,9 @@ async function promptProviderConfig(
if (source === "file") { if (source === "file") {
return await promptFileProvider(current?.source === "file" ? current : undefined); return await promptFileProvider(current?.source === "file" ? current : undefined);
} }
if (source === "store") {
return { source: "store" };
}
return await promptExecProvider( return await promptExecProvider(
current?.source === "exec" && "command" in current ? current : undefined, current?.source === "exec" && "command" in current ? current : undefined,
); );
@@ -653,7 +665,7 @@ async function configureProvidersInteractive(
{ {
value: "add", value: "add",
label: "Add provider", label: "Add provider",
hint: "Define a new env/file/exec provider", hint: "Define a new env/file/exec/store provider",
}, },
]; ];
if (presetEntries.length > 0) { if (presetEntries.length > 0) {
@@ -686,7 +698,7 @@ async function configureProvidersInteractive(
message: message:
providerEntries.length > 0 providerEntries.length > 0
? "Configure secret providers" ? "Configure secret providers"
: "Configure secret providers (only env refs are available until file/exec providers are added)", : "Configure secret providers (env/store refs are built in; add file/exec providers as needed)",
options: actionOptions, options: actionOptions,
}), }),
"Secrets configure cancelled.", "Secrets configure cancelled.",
@@ -960,7 +972,7 @@ export async function runSecretsConfigureInteractive(
const suggestedIdFromExistingRef = const suggestedIdFromExistingRef =
existingRef?.source === source ? existingRef.id : undefined; existingRef?.source === source ? existingRef.id : undefined;
let suggestedId = suggestedIdFromExistingRef; let suggestedId = suggestedIdFromExistingRef;
if (!suggestedId && source === "env") { if (!suggestedId && (source === "env" || source === "store")) {
suggestedId = resolveSuggestedEnvSecretId(candidate); suggestedId = resolveSuggestedEnvSecretId(candidate);
} }
if (!suggestedId && source === "file") { if (!suggestedId && source === "file") {
@@ -978,6 +990,9 @@ export async function runSecretsConfigureInteractive(
if (!trimmed) { if (!trimmed) {
return "Required"; return "Required";
} }
if ((source === "env" || source === "store") && !isValidEnvSecretRefId(trimmed)) {
return `${source} ids must match /^[A-Z][A-Z0-9_]{0,127}$/`;
}
if (source === "exec" && !isValidExecSecretRefId(trimmed)) { if (source === "exec" && !isValidExecSecretRefId(trimmed)) {
return formatExecSecretRefIdValidationMessage(); return formatExecSecretRefIdValidationMessage();
} }
+4 -1
View File
@@ -130,7 +130,10 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
!resolved || !resolved ||
!ref || !ref ||
typeof ref !== "object" || typeof ref !== "object" ||
(ref.source !== "env" && ref.source !== "file" && ref.source !== "exec") || (ref.source !== "env" &&
ref.source !== "file" &&
ref.source !== "exec" &&
ref.source !== "store") ||
typeof ref.provider !== "string" || typeof ref.provider !== "string" ||
ref.provider.trim().length === 0 || ref.provider.trim().length === 0 ||
typeof ref.id !== "string" || typeof ref.id !== "string" ||
+11
View File
@@ -10,6 +10,7 @@ import {
isValidExecSecretRefId, isValidExecSecretRefId,
isValidFileSecretRefId, isValidFileSecretRefId,
isValidSecretRef, isValidSecretRef,
resolveDefaultSecretProviderAlias,
validateExecSecretRefId, validateExecSecretRefId,
} from "./ref-contract.js"; } from "./ref-contract.js";
@@ -68,4 +69,14 @@ describe("secret ref validation", () => {
} as never), } as never),
).toBe(false); ).toBe(false);
}); });
it("uses env-name grammar and source-specific defaults for store refs", () => {
expect(isValidSecretRef({ source: "store", provider: "default", id: "STORED_API_KEY" })).toBe(
true,
);
expect(isValidSecretRef({ source: "store", provider: "default", id: "lowercase" })).toBe(false);
expect(
resolveDefaultSecretProviderAlias({ secrets: { defaults: { store: "teamstore" } } }, "store"),
).toBe("teamstore");
});
}); });
+7 -7
View File
@@ -13,7 +13,7 @@ import {
*/ */
const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/; const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/;
/** Shared alias grammar for env/file/exec secret provider names. */ /** Shared alias grammar for env/file/exec/store secret provider names. */
export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/; const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/;
@@ -43,6 +43,8 @@ type SecretRefDefaultsCarrier = {
file?: string; file?: string;
/** Default provider alias for exec-backed secret refs. */ /** Default provider alias for exec-backed secret refs. */
exec?: string; exec?: string;
/** Default provider alias for shared-store secret refs. */
store?: string;
}; };
/** Provider declarations used only when callers ask to prefer the first matching source. */ /** Provider declarations used only when callers ask to prefer the first matching source. */
providers?: Record<string, { source?: string }>; providers?: Record<string, { source?: string }>;
@@ -60,12 +62,7 @@ export function resolveDefaultSecretProviderAlias(
source: SecretRefSource, source: SecretRefSource,
options?: { preferFirstProviderForSource?: boolean }, options?: { preferFirstProviderForSource?: boolean },
): string { ): string {
const configured = const configured = config.secrets?.defaults?.[source];
source === "env"
? config.secrets?.defaults?.env
: source === "file"
? config.secrets?.defaults?.file
: config.secrets?.defaults?.exec;
if (configured?.trim()) { if (configured?.trim()) {
return configured.trim(); return configured.trim();
} }
@@ -141,6 +138,9 @@ export function isValidSecretRef(ref: SecretRef): boolean {
if (ref.source === "file") { if (ref.source === "file") {
return isValidFileSecretRefId(ref.id); return isValidFileSecretRefId(ref.id);
} }
if (ref.source === "store") {
return isValidEnvSecretRefId(ref.id);
}
return isValidExecSecretRefId(ref.id); return isValidExecSecretRefId(ref.id);
} }
+69
View File
@@ -0,0 +1,69 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { describeSecretResolutionError } from "./resolve-errors.js";
import { resolveSecretRefString } from "./resolve.js";
import { isRetryableSecretDegradationReason } from "./runtime-degraded-state.js";
import { writeSecretStoreEntry } from "./store/secret-store.js";
const roots: string[] = [];
async function createStateEnv(): Promise<NodeJS.ProcessEnv> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-resolve-store-"));
roots.push(root);
return { OPENCLAW_STATE_DIR: path.join(root, "state") };
}
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
describe("store SecretRef resolution", () => {
it("resolves a team store value through the implicit default provider", async () => {
const env = await createStateEnv();
writeSecretStoreEntry({
scope: { kind: "team" },
name: "STORED_API_KEY",
value: "resolved-store-secret",
kind: "secret",
updatedBy: "test",
database: { env },
});
await expect(
resolveSecretRefString(
{ source: "store", provider: "default", id: "STORED_API_KEY" },
{ config: {}, env },
),
).resolves.toBe("resolved-store-secret");
});
it("maps a missing name to a retryable not-found degradation", async () => {
const env = await createStateEnv();
const error = await resolveSecretRefString(
{ source: "store", provider: "default", id: "MISSING_API_KEY" },
{ config: {}, env },
).catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "SECRET_REF_NOT_FOUND", source: "store" });
const reason = describeSecretResolutionError(error);
expect(reason).toBe("secret reference was not found");
expect(isRetryableSecretDegradationReason(reason ?? "")).toBe(true);
});
it("maps database access failures to provider unavailable", async () => {
const env = await createStateEnv();
await fs.mkdir(path.dirname(env.OPENCLAW_STATE_DIR as string), { recursive: true });
await fs.writeFile(env.OPENCLAW_STATE_DIR as string, "not a directory", "utf8");
const error = await resolveSecretRefString(
{ source: "store", provider: "default", id: "STORED_API_KEY" },
{ config: {}, env },
).catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "SECRET_PROVIDER_UNAVAILABLE", source: "store" });
expect(describeSecretResolutionError(error)).toBe("secret provider failed");
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { SecretRef } from "../config/types.secrets.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { providerResolutionError, refResolutionError } from "./resolve-errors.js";
import { readSecretStoreValue, SECRET_STORE_VALUE_MAX_BYTES } from "./store/secret-store.js";
// Store values intentionally support large PEM/JSON payloads, so this batch cap is
// independent from the 256 KiB request cap used by file and exec providers.
const STORE_SECRET_REF_BATCH_MAX_BYTES = 512 * SECRET_STORE_VALUE_MAX_BYTES;
export function resolveStoreRefs(params: {
refs: SecretRef[];
providerName: string;
database?: OpenClawStateDatabaseOptions;
}): Map<string, unknown> {
const resolved = new Map<string, unknown>();
let resolvedBytes = 0;
for (const ref of params.refs) {
const result = readSecretStoreValue({
scope: { kind: "team" },
name: ref.id,
database: params.database,
});
if (!result.ok) {
if (result.error.code === "SECRET_STORE_NOT_FOUND") {
throw refResolutionError({
code: "SECRET_REF_NOT_FOUND",
source: "store",
provider: params.providerName,
refId: ref.id,
message: result.error.message,
});
}
if (result.error.code === "SECRET_STORE_INVALID_NAME") {
throw refResolutionError({
code: "SECRET_REF_INVALID",
source: "store",
provider: params.providerName,
refId: ref.id,
message: result.error.message,
});
}
throw providerResolutionError({
code: "SECRET_PROVIDER_UNAVAILABLE",
source: "store",
provider: params.providerName,
message: result.error.message,
cause: result.error.cause,
});
}
resolvedBytes += Buffer.byteLength(result.value, "utf8");
if (resolvedBytes > STORE_SECRET_REF_BATCH_MAX_BYTES) {
throw providerResolutionError({
code: "SECRET_PROVIDER_INVALID",
source: "store",
provider: params.providerName,
message: `Store provider "${params.providerName}" exceeded its ${STORE_SECRET_REF_BATCH_MAX_BYTES}-byte batch limit.`,
});
}
resolved.set(ref.id, result.value);
}
return resolved;
}
+20 -1
View File
@@ -1,4 +1,4 @@
/** Resolves SecretRef values from env, file, and exec secret providers. */ /** Resolves SecretRef values from env, file, exec, and store secret providers. */
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core"; import { expectDefined } from "@openclaw/normalization-core";
@@ -46,6 +46,7 @@ import {
providerResolutionError, providerResolutionError,
refResolutionError, refResolutionError,
} from "./resolve-errors.js"; } from "./resolve-errors.js";
import { resolveStoreRefs } from "./resolve-store.js";
import type { SecretRefResolveCache } from "./resolve-types.js"; import type { SecretRefResolveCache } from "./resolve-types.js";
import { import {
isNonEmptyString, isNonEmptyString,
@@ -160,6 +161,12 @@ function resolveConfiguredProvider(params: {
if (ref.source === "env" && ref.provider === resolveDefaultSecretProviderAlias(config, "env")) { if (ref.source === "env" && ref.provider === resolveDefaultSecretProviderAlias(config, "env")) {
return { source: "env" }; return { source: "env" };
} }
if (
ref.source === "store" &&
ref.provider === resolveDefaultSecretProviderAlias(config, "store")
) {
return { source: "store" };
}
throw providerResolutionError({ throw providerResolutionError({
code: "SECRET_PROVIDER_NOT_CONFIGURED", code: "SECRET_PROVIDER_NOT_CONFIGURED",
source: ref.source, source: ref.source,
@@ -675,6 +682,13 @@ async function resolveProviderRefs(params: {
cache: params.options.cache, cache: params.options.cache,
}); });
} }
if (params.providerConfig.source === "store") {
return resolveStoreRefs({
refs: params.refs,
providerName: params.providerName,
database: { env: params.options.env ?? process.env },
});
}
if (params.providerConfig.source === "exec") { if (params.providerConfig.source === "exec") {
if (isPluginIntegrationSecretProviderConfig(params.providerConfig)) { if (isPluginIntegrationSecretProviderConfig(params.providerConfig)) {
throw providerResolutionError({ throw providerResolutionError({
@@ -730,6 +744,11 @@ function normalizeAndGroupSecretRefs(refs: SecretRef[]): ProviderRefGroup[] {
`File secret reference id must be an absolute JSON pointer or "value" (ref: ${ref.source}:${ref.provider}:${id}).`, `File secret reference id must be an absolute JSON pointer or "value" (ref: ${ref.source}:${ref.provider}:${id}).`,
); );
} }
if (ref.source === "store" && !isValidEnvSecretRefId(id)) {
throw new Error(
`Store secret reference id must match /^[A-Z][A-Z0-9_]{0,127}$/ (ref: ${ref.source}:${ref.provider}:${id}).`,
);
}
if (ref.source === "exec" && !isValidExecSecretRefId(id)) { if (ref.source === "exec" && !isValidExecSecretRefId(id)) {
throw new Error( throw new Error(
`${formatExecSecretRefIdValidationMessage()} (ref: ${ref.source}:${ref.provider}:${id}).`, `${formatExecSecretRefIdValidationMessage()} (ref: ${ref.source}:${ref.provider}:${id}).`,
+40
View File
@@ -0,0 +1,40 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
const roots: string[] = [];
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
describe("store SecretRef runtime degradation", () => {
it("isolates a missing store-backed skill instead of failing gateway startup", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-runtime-store-"));
roots.push(root);
const ref = { source: "store", provider: "default", id: "MISSING_SKILL_API_KEY" } as const;
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
agents: { list: [{ id: "main", default: true }] },
skills: { entries: { unavailable: { apiKey: ref } } },
}),
env: { OPENCLAW_STATE_DIR: path.join(root, "state") },
includeAuthStoreRefs: false,
allowUnavailableSecretOwners: true,
loadablePluginOrigins: new Map(),
});
expect(snapshot.config.skills?.entries?.unavailable?.apiKey).toEqual(ref);
expect(snapshot.degradedOwners).toMatchObject([
{
ownerKind: "capability",
ownerId: "skill:unavailable",
state: "unavailable",
reason: "secret reference was not found",
},
]);
});
});
+7
View File
@@ -0,0 +1,7 @@
/** Matches environment names whose suffix convention indicates credential material. */
export const SECRET_ENV_NAME_RE = /_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i;
/** Classifies the default secret-store kind from an environment-style name. */
export function isSensitiveEnvName(name: string): boolean {
return SECRET_ENV_NAME_RE.test(name);
}
+181
View File
@@ -0,0 +1,181 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import {
deleteSecretStoreEntry,
listSecretStoreEntries,
purgeExpiredSecretStoreEntries,
readSecretStoreValue,
SECRET_STORE_VALUE_MAX_BYTES,
writeSecretStoreEntry,
} from "./secret-store.js";
const roots: string[] = [];
const team = { kind: "team" } as const;
function createDatabaseOptions() {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-secret-store-")));
roots.push(root);
return { path: path.join(root, "state.sqlite") };
}
afterEach(() => {
vi.useRealTimers();
closeOpenClawStateDatabaseForTest();
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe("secret store", () => {
it("round-trips env and secret entries without disclosing secret list values", () => {
const database = createDatabaseOptions();
writeSecretStoreEntry({
scope: team,
name: "SERVICE_URL",
value: "https://service.test",
kind: "env",
updatedBy: "test",
database,
});
writeSecretStoreEntry({
scope: team,
name: "SERVICE_API_KEY",
value: "stored-super-secret",
kind: "secret",
updatedBy: "test",
database,
});
expect(listSecretStoreEntries({ scope: team, database })).toEqual([
expect.objectContaining({ name: "SERVICE_API_KEY", kind: "secret" }),
expect.objectContaining({
name: "SERVICE_URL",
kind: "env",
valuePreview: "https://service.test",
}),
]);
expect(listSecretStoreEntries({ scope: team, database })[0]).not.toHaveProperty("valuePreview");
expect(readSecretStoreValue({ scope: team, name: "SERVICE_API_KEY", database })).toEqual({
ok: true,
value: "stored-super-secret",
});
expect(isSecretValueRegisteredForRedaction("stored-super-secret")).toBe(true);
});
it("soft-deletes idempotently and purges after the 30-day retention", () => {
const database = createDatabaseOptions();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
writeSecretStoreEntry({
scope: team,
name: "DELETE_TOKEN",
value: "delete-me",
kind: "secret",
updatedBy: null,
database,
});
deleteSecretStoreEntry({ scope: team, name: "DELETE_TOKEN", database });
deleteSecretStoreEntry({ scope: team, name: "DELETE_TOKEN", database });
expect(listSecretStoreEntries({ scope: team, database })).toEqual([]);
expect(listSecretStoreEntries({ scope: team, includeDeleted: true, database })).toHaveLength(1);
expect(purgeExpiredSecretStoreEntries({ database })).toBe(0);
vi.setSystemTime(new Date("2026-02-01T00:00:00.001Z"));
expect(purgeExpiredSecretStoreEntries({ database })).toBe(1);
expect(listSecretStoreEntries({ scope: team, includeDeleted: true, database })).toEqual([]);
});
it("makes duplicate team rows impossible at the schema boundary", () => {
const database = createDatabaseOptions();
writeSecretStoreEntry({
scope: team,
name: "UNIQUE_TOKEN",
value: "first-value",
kind: "secret",
updatedBy: null,
database,
});
const state = openOpenClawStateDatabase(database);
expect(() =>
state.db
.prepare(
"INSERT INTO secret_store_entries (scope_kind, scope_id, name, value, kind, created_at_ms, updated_at_ms) VALUES ('team', '', 'UNIQUE_TOKEN', 'duplicate', 'secret', 1, 1)",
)
.run(),
).toThrow(/UNIQUE constraint failed/u);
});
it("rejects invalid names and values over the UTF-8 byte cap", () => {
const database = createDatabaseOptions();
expect(() =>
writeSecretStoreEntry({
scope: team,
name: "lowercase",
value: "value",
kind: "env",
updatedBy: null,
database,
}),
).toThrow(expect.objectContaining({ code: "SECRET_STORE_INVALID_NAME" }));
expect(() =>
writeSecretStoreEntry({
scope: team,
name: "LARGE_SECRET",
value: "é".repeat(SECRET_STORE_VALUE_MAX_BYTES / 2 + 1),
kind: "secret",
updatedBy: null,
database,
}),
).toThrow(expect.objectContaining({ code: "SECRET_STORE_VALUE_TOO_LARGE" }));
});
it("treats a missing lazy table as empty and preserves schema version 6 on ensure", () => {
const database = createDatabaseOptions();
openOpenClawStateDatabase(database);
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const before = new DatabaseSync(database.path);
expect(before.prepare("PRAGMA user_version").get()).toEqual({ user_version: 6 });
before.exec("DROP TABLE secret_store_entries;");
before.close();
expect(listSecretStoreEntries({ scope: team, database })).toEqual([]);
expect(readSecretStoreValue({ scope: team, name: "MISSING_SECRET", database })).toMatchObject({
ok: false,
error: { code: "SECRET_STORE_NOT_FOUND" },
});
const stillMissing = new DatabaseSync(database.path, { readOnly: true });
expect(
stillMissing
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get("secret_store_entries"),
).toBeUndefined();
stillMissing.close();
writeSecretStoreEntry({
scope: team,
name: "CREATED_SECRET",
value: "created-after-lazy-ensure",
kind: "secret",
updatedBy: null,
database,
});
closeOpenClawStateDatabaseForTest();
const after = new DatabaseSync(database.path, { readOnly: true });
expect(after.prepare("PRAGMA user_version").get()).toEqual({ user_version: 6 });
expect(
after
.prepare("SELECT name FROM sqlite_schema WHERE type = 'index' AND name = ?")
.get("secret_store_entries_live_idx"),
).toEqual({ name: "secret_store_entries_live_idx" });
after.close();
});
});
+330
View File
@@ -0,0 +1,330 @@
import type { DatabaseSync } from "node:sqlite";
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import type { Selectable } from "kysely";
import { ENV_SECRET_REF_ID_RE } from "../../config/types.secrets.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { normalizeSqliteNumber } from "../../infra/sqlite-number.js";
import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js";
import { withExistingOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js";
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../../state/openclaw-state-db.js";
type SecretStoreDatabase = Pick<OpenClawStateKyselyDatabase, "secret_store_entries">;
type SecretStoreRow = Selectable<OpenClawStateKyselyDatabase["secret_store_entries"]>;
type SecretStoreScope = { kind: "team" };
type SecretStoreKind = "secret" | "env";
export type SecretStoreEntryMetadata = {
name: string;
kind: SecretStoreKind;
scopeKind: "team" | "identity";
scopeId: string;
updatedAtMs: number;
createdAtMs: number;
updatedBy: string | null;
valuePreview?: string;
};
type SecretStoreReadError =
| { code: "SECRET_STORE_NOT_FOUND"; message: string }
| { code: "SECRET_STORE_INVALID_NAME"; message: string }
| { code: "SECRET_STORE_UNAVAILABLE"; message: string; cause: unknown };
type SecretStoreValidationCode = "SECRET_STORE_INVALID_NAME" | "SECRET_STORE_VALUE_TOO_LARGE";
export class SecretStoreValidationError extends Error {
constructor(
readonly code: SecretStoreValidationCode,
message: string,
) {
super(message);
this.name = "SecretStoreValidationError";
}
}
export const SECRET_STORE_VALUE_MAX_BYTES = 64 * 1024;
const SECRET_STORE_RETENTION_MS = 30 * 24 * 60 * 60_000;
const ensuredDatabases = new WeakSet<DatabaseSync>();
// Keep this feature-local DDL byte-for-byte aligned with the canonical schema.
const SECRET_STORE_SCHEMA_SQL = `
-- scope_id is non-null because SQLite treats NULLs as distinct in unique indexes/PKs,
-- which would allow duplicate team rows. This PK also avoids a rebuild for identity scope.
CREATE TABLE IF NOT EXISTS secret_store_entries (
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('team', 'identity')),
scope_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('secret', 'env')),
created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
updated_by TEXT,
deleted_at_ms INTEGER,
CHECK ((scope_kind = 'team' AND scope_id = '') OR (scope_kind = 'identity' AND length(scope_id) > 0)),
PRIMARY KEY (scope_kind, scope_id, name)
) STRICT;
CREATE INDEX IF NOT EXISTS secret_store_entries_live_idx
ON secret_store_entries (scope_kind, scope_id, name) WHERE deleted_at_ms IS NULL;
`;
function normalizeScope(_scope: SecretStoreScope): { scopeKind: "team"; scopeId: "" } {
return { scopeKind: "team", scopeId: "" };
}
function assertSecretStoreName(name: string): void {
if (!ENV_SECRET_REF_ID_RE.test(name)) {
throw new SecretStoreValidationError(
"SECRET_STORE_INVALID_NAME",
`Secret store name must match ${String(ENV_SECRET_REF_ID_RE)}.`,
);
}
}
function assertSecretStoreValue(value: string): void {
const bytes = Buffer.byteLength(value, "utf8");
if (bytes > SECRET_STORE_VALUE_MAX_BYTES) {
throw new SecretStoreValidationError(
"SECRET_STORE_VALUE_TOO_LARGE",
`Secret store value exceeds ${SECRET_STORE_VALUE_MAX_BYTES} UTF-8 bytes.`,
);
}
}
function isMissingSecretStoreTableError(error: unknown): boolean {
return (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === "ERR_SQLITE_ERROR" &&
error.message === "no such table: secret_store_entries"
);
}
function ensureSecretStoreSchema(options: OpenClawStateDatabaseOptions = {}): void {
const database = openOpenClawStateDatabase(options);
if (ensuredDatabases.has(database.db)) {
return;
}
runOpenClawStateWriteTransaction(
({ db }) => {
// sqlite-allow-raw -- feature-local additive schema DDL; secret rows use Kysely below.
db.exec(SECRET_STORE_SCHEMA_SQL);
},
options,
{ operationLabel: "secrets.store.schema.ensure" },
);
ensuredDatabases.add(database.db);
}
function toMetadata(row: SecretStoreRow): SecretStoreEntryMetadata {
if (row.kind === "secret") {
registerSecretValueForRedaction(row.value);
}
return {
name: row.name,
kind: row.kind as SecretStoreKind,
scopeKind: row.scope_kind as "team" | "identity",
scopeId: row.scope_id,
updatedAtMs: normalizeSqliteNumber(row.updated_at_ms) ?? 0,
createdAtMs: normalizeSqliteNumber(row.created_at_ms) ?? 0,
updatedBy: row.updated_by,
...(row.kind === "env" ? { valuePreview: row.value } : {}),
};
}
export function listSecretStoreEntries(params: {
scope: SecretStoreScope;
includeDeleted?: boolean;
database?: OpenClawStateDatabaseOptions;
}): SecretStoreEntryMetadata[] {
const { scopeKind, scopeId } = normalizeScope(params.scope);
try {
return (
withExistingOpenClawStateDatabaseReadOnly(({ db: sqlite }) => {
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
let query = db
.selectFrom("secret_store_entries")
.selectAll()
.where("scope_kind", "=", scopeKind)
.where("scope_id", "=", scopeId)
.orderBy("name", "asc");
if (!params.includeDeleted) {
query = query.where("deleted_at_ms", "is", null);
}
return executeSqliteQuerySync(sqlite, query).rows.map(toMetadata);
}, params.database ?? {}) ?? []
);
} catch (error) {
if (isMissingSecretStoreTableError(error)) {
return [];
}
throw error;
}
}
export function readSecretStoreValue(params: {
scope: SecretStoreScope;
name: string;
database?: OpenClawStateDatabaseOptions;
}): Result<string, SecretStoreReadError> {
try {
assertSecretStoreName(params.name);
const { scopeKind, scopeId } = normalizeScope(params.scope);
const row = withExistingOpenClawStateDatabaseReadOnly(({ db: sqlite }) => {
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
return executeSqliteQueryTakeFirstSync(
sqlite,
db
.selectFrom("secret_store_entries")
.select(["value", "kind"])
.where("scope_kind", "=", scopeKind)
.where("scope_id", "=", scopeId)
.where("name", "=", params.name)
.where("deleted_at_ms", "is", null),
);
}, params.database ?? {});
if (!row) {
return err({
code: "SECRET_STORE_NOT_FOUND",
message: `Secret store entry "${params.name}" was not found.`,
});
}
if (row.kind === "secret") {
registerSecretValueForRedaction(row.value);
}
return ok(row.value);
} catch (error) {
if (isMissingSecretStoreTableError(error)) {
return err({
code: "SECRET_STORE_NOT_FOUND",
message: `Secret store entry "${params.name}" was not found.`,
});
}
if (error instanceof SecretStoreValidationError) {
return err({ code: "SECRET_STORE_INVALID_NAME", message: error.message });
}
return err({
code: "SECRET_STORE_UNAVAILABLE",
message: "Secret store database is unavailable.",
cause: error,
});
}
}
export function writeSecretStoreEntry(params: {
scope: SecretStoreScope;
name: string;
value: string;
kind: SecretStoreKind;
updatedBy: string | null;
database?: OpenClawStateDatabaseOptions;
}): void {
assertSecretStoreName(params.name);
assertSecretStoreValue(params.value);
ensureSecretStoreSchema(params.database);
const { scopeKind, scopeId } = normalizeScope(params.scope);
const now = Date.now();
runOpenClawStateWriteTransaction(
({ db: sqlite }) => {
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
executeSqliteQuerySync(
sqlite,
db
.insertInto("secret_store_entries")
.values({
scope_kind: scopeKind,
scope_id: scopeId,
name: params.name,
value: params.value,
kind: params.kind,
created_at_ms: now,
updated_at_ms: now,
updated_by: params.updatedBy,
deleted_at_ms: null,
})
.onConflict((conflict) =>
conflict.columns(["scope_kind", "scope_id", "name"]).doUpdateSet({
value: params.value,
kind: params.kind,
updated_at_ms: now,
updated_by: params.updatedBy,
deleted_at_ms: null,
}),
),
);
},
params.database,
{ operationLabel: "secrets.store.write" },
);
}
export function deleteSecretStoreEntry(params: {
scope: SecretStoreScope;
name: string;
database?: OpenClawStateDatabaseOptions;
}): void {
assertSecretStoreName(params.name);
const { scopeKind, scopeId } = normalizeScope(params.scope);
const state = openOpenClawStateDatabase(params.database);
const now = Date.now();
try {
runOpenClawStateWriteTransaction(
({ db: sqlite }) => {
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
executeSqliteQuerySync(
sqlite,
db
.updateTable("secret_store_entries")
.set({ deleted_at_ms: now, updated_at_ms: now })
.where("scope_kind", "=", scopeKind)
.where("scope_id", "=", scopeId)
.where("name", "=", params.name)
.where("deleted_at_ms", "is", null),
);
},
{ ...params.database, database: state },
{ operationLabel: "secrets.store.delete" },
);
} catch (error) {
if (!isMissingSecretStoreTableError(error)) {
throw error;
}
}
}
export function purgeExpiredSecretStoreEntries(
params: {
database?: OpenClawStateDatabaseOptions;
} = {},
): number {
const state = openOpenClawStateDatabase(params.database);
const threshold = Date.now() - SECRET_STORE_RETENTION_MS;
try {
return runOpenClawStateWriteTransaction(
({ db: sqlite }) => {
const db = getNodeSqliteKysely<SecretStoreDatabase>(sqlite);
const deleted = executeSqliteQuerySync(
sqlite,
db
.deleteFrom("secret_store_entries")
.where("deleted_at_ms", "is not", null)
.where("deleted_at_ms", "<", threshold),
);
return Number(deleted.numAffectedRows ?? 0n);
},
{ ...params.database, database: state },
{ operationLabel: "secrets.store.purge" },
);
} catch (error) {
if (isMissingSecretStoreTableError(error)) {
return 0;
}
throw error;
}
}
+5 -1
View File
@@ -14,6 +14,7 @@ export const FIRST_USE_STATE_INDEXES = ["execution_identity_contexts_run_created
export const LAZY_ADDITIVE_STATE_TABLES = [ export const LAZY_ADDITIVE_STATE_TABLES = [
...FIRST_USE_STATE_TABLES, ...FIRST_USE_STATE_TABLES,
"model_catalog_remote", "model_catalog_remote",
"secret_store_entries",
"gateway_origin_device_tokens", "gateway_origin_device_tokens",
"sidebar_sections", "sidebar_sections",
"skill_workshop_proposal_events", "skill_workshop_proposal_events",
@@ -22,7 +23,10 @@ export const LAZY_ADDITIVE_STATE_TABLES = [
"skill_workshop_proposals", "skill_workshop_proposals",
"worker_environment_ssh_fallback_ports", "worker_environment_ssh_fallback_ports",
] as const; ] as const;
export const LAZY_ADDITIVE_STATE_INDEXES = [...FIRST_USE_STATE_INDEXES] as const; export const LAZY_ADDITIVE_STATE_INDEXES = [
...FIRST_USE_STATE_INDEXES,
"secret_store_entries_live_idx",
] as const;
/** Maximum time one synchronous SQLite call may wait for a lock. */ /** Maximum time one synchronous SQLite call may wait for a lock. */
export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000; export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000;
/** User-facing guide for schema refusals; lives here so error sites avoid import cycles. */ /** User-facing guide for schema refusals; lives here so error sites avoid import cycles. */
+13
View File
@@ -1055,6 +1055,18 @@ export interface SchemaMeta {
updated_at: number; updated_at: number;
} }
export interface SecretStoreEntries {
created_at_ms: number;
deleted_at_ms: number | null;
kind: string;
name: string;
scope_id: string;
scope_kind: string;
updated_at_ms: number;
updated_by: string | null;
value: string;
}
export interface SessionGroups { export interface SessionGroups {
created_at: number; created_at: number;
name: string; name: string;
@@ -1657,6 +1669,7 @@ export interface DB {
plugin_state_entries: PluginStateEntries; plugin_state_entries: PluginStateEntries;
sandbox_registry_entries: SandboxRegistryEntries; sandbox_registry_entries: SandboxRegistryEntries;
schema_meta: SchemaMeta; schema_meta: SchemaMeta;
secret_store_entries: SecretStoreEntries;
session_groups: SessionGroups; session_groups: SessionGroups;
session_state_events: SessionStateEvents; session_state_events: SessionStateEvents;
session_state_heads: SessionStateHeads; session_state_heads: SessionStateHeads;
+18
View File
@@ -2238,3 +2238,21 @@ CREATE TABLE IF NOT EXISTS model_catalog_remote (
last_modified TEXT, last_modified TEXT,
checked_at INTEGER NOT NULL checked_at INTEGER NOT NULL
) STRICT; ) STRICT;
-- scope_id is non-null because SQLite treats NULLs as distinct in unique indexes/PKs,
-- which would allow duplicate team rows. This PK also avoids a rebuild for identity scope.
CREATE TABLE IF NOT EXISTS secret_store_entries (
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('team', 'identity')),
scope_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('secret', 'env')),
created_at_ms INTEGER NOT NULL CHECK (created_at_ms >= 0),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
updated_by TEXT,
deleted_at_ms INTEGER,
CHECK ((scope_kind = 'team' AND scope_id = '') OR (scope_kind = 'identity' AND length(scope_id) > 0)),
PRIMARY KEY (scope_kind, scope_id, name)
) STRICT;
CREATE INDEX IF NOT EXISTS secret_store_entries_live_idx
ON secret_store_entries (scope_kind, scope_id, name) WHERE deleted_at_ms IS NULL;
+1 -1
View File
@@ -18,7 +18,7 @@ export type SystemAgentOperation =
| { | {
kind: "config-set-ref"; kind: "config-set-ref";
path: string; path: string;
source: "env" | "file" | "exec"; source: "env" | "file" | "exec" | "store";
id: string; id: string;
provider?: string; provider?: string;
} }
+2 -2
View File
@@ -86,7 +86,7 @@ const CONFIG_SCHEMA_RE = new RegExp(
"i", "i",
); );
const CONFIG_SET_REF_RE = new RegExp( const CONFIG_SET_REF_RE = new RegExp(
String.raw`^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?<path>${CONFIG_PATH})\s+(?:(?<source>env|file|exec)\s+)?(?<id>\S+)(?:\s+provider\s+(?<provider>[A-Za-z0-9_-]+))?$`, String.raw`^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?<path>${CONFIG_PATH})\s+(?:(?<source>env|file|exec|store)\s+)?(?<id>\S+)(?:\s+provider\s+(?<provider>[A-Za-z0-9_-]+))?$`,
"i", "i",
); );
const SETUP_RE = new RegExp( const SETUP_RE = new RegExp(
@@ -194,7 +194,7 @@ export function parseSystemAgentOperation(input: string): SystemAgentOperation {
return { return {
kind: "config-set-ref", kind: "config-set-ref",
path: configSetRefMatch.groups.path, path: configSetRefMatch.groups.path,
source: source as "env" | "file" | "exec", source: source as "env" | "file" | "exec" | "store",
id: configSetRefMatch.groups.id.trim(), id: configSetRefMatch.groups.id.trim(),
...(configSetRefMatch.groups.provider ? { provider: configSetRefMatch.groups.provider } : {}), ...(configSetRefMatch.groups.provider ? { provider: configSetRefMatch.groups.provider } : {}),
}; };
+8
View File
@@ -285,6 +285,14 @@ describe("parseSystemAgentOperation", () => {
source: "env", source: "env",
id: "GATEWAY_TOKEN", id: "GATEWAY_TOKEN",
}); });
expect(
parseSystemAgentOperation("config set-ref gateway.auth.token store GATEWAY_TOKEN"),
).toEqual({
kind: "config-set-ref",
path: "gateway.auth.token",
source: "store",
id: "GATEWAY_TOKEN",
});
expect(parseSystemAgentOperation("doctor fix")).toEqual({ kind: "doctor-fix" }); expect(parseSystemAgentOperation("doctor fix")).toEqual({ kind: "doctor-fix" });
}); });
+2 -1
View File
@@ -154,7 +154,8 @@ function parsePendingOperation(value: unknown): SystemAgentOperation | null {
!isNonEmptyString(operation.path) || !isNonEmptyString(operation.path) ||
(operation.source !== "env" && (operation.source !== "env" &&
operation.source !== "file" && operation.source !== "file" &&
operation.source !== "exec") || operation.source !== "exec" &&
operation.source !== "store") ||
!isNonEmptyString(operation.id) || !isNonEmptyString(operation.id) ||
!hasOptionalString(operation, "provider") !hasOptionalString(operation, "provider")
) { ) {