mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(secrets): add SQLite-backed secret store (#121559)
This commit is contained in:
committed by
GitHub
parent
b121219a44
commit
f4bac99a81
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2291,
|
||||
"core": 2292,
|
||||
"channel": 3716,
|
||||
"plugin": 4030
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
6d70719d3e4b0ff86c96852be6efd7ec86cc85cadd07e27e79b2b22c14aed5b9 config-baseline.json
|
||||
7832494e638045df79a3ba0d25964fb5100f9d7a1e7d8d38a9174f7b6193bf52 config-baseline.core.json
|
||||
05150969476529ddc78ae3836efb00419571bf706696e91f23af226571fd2562 config-baseline.json
|
||||
52a8577126e605f70ef6c34cafaaebd83ec7a658b70da20ebb24b6eb37a0e513 config-baseline.core.json
|
||||
3a8d0cbdbf9d7d603204fba5b93493050cb76fd9141fcbbec56159ff00158d6f config-baseline.channel.json
|
||||
4498ac72bb6b9cd5205f7bb8741110a4c53c4e2ec504a1dbb7e7e59c3495f492 config-baseline.plugin.json
|
||||
|
||||
+2460
-2460
File diff suppressed because one or more lines are too long
@@ -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. |
|
||||
| `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`. |
|
||||
| `workspace` | none (required) | Workspace id, slug, or name. |
|
||||
| `replyMode` | `"agent"` | `"agent"` runs the full agent pipeline; `"model"` sends short direct model completions. |
|
||||
|
||||
@@ -150,7 +150,7 @@ openclaw gateway
|
||||
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.
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ Use these identifiers for delivery and allowlists:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
- `proxy`: optional HTTP(S) proxy URL for Matrix traffic. Per-account override supported.
|
||||
- `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.
|
||||
- `deviceId`: explicit Matrix device ID.
|
||||
- `deviceName`: device display name used at password-login time.
|
||||
|
||||
@@ -218,7 +218,7 @@ Then enable the channel in config:
|
||||
|
||||
### 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
|
||||
{
|
||||
|
||||
+2
-2
@@ -224,7 +224,7 @@ Provider builder targets must use `secrets.providers.<alias>` as the path.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Common flags">
|
||||
- `--provider-source <env|file|exec>`
|
||||
- `--provider-source <env|file|exec|store>`
|
||||
- `--provider-timeout-ms <ms>` (`file`, `exec`)
|
||||
|
||||
</Accordion>
|
||||
@@ -443,7 +443,7 @@ openclaw config set channels.discord.token \
|
||||
<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 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.
|
||||
- `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.
|
||||
|
||||
+1
-1
@@ -264,7 +264,7 @@ openclaw onboard --non-interactive \
|
||||
--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)
|
||||
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
`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`,
|
||||
then `gateway.remote.url` when `gateway.mode` is `remote`, then the local
|
||||
|
||||
+83
-3
@@ -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:
|
||||
- Re-resolving secret refs at runtime
|
||||
- Managing team-scoped values in the shared secret store
|
||||
- Auditing plaintext residues and unresolved refs
|
||||
- Configuring SecretRefs and applying one-way scrub changes
|
||||
title: "Secrets"
|
||||
@@ -14,6 +15,7 @@ Manage SecretRefs and keep the active runtime snapshot healthy.
|
||||
| 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 |
|
||||
| `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`) |
|
||||
| `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 |
|
||||
@@ -35,9 +37,86 @@ Exit codes for CI/gates:
|
||||
|
||||
- `audit --check` returns `1` on findings.
|
||||
- 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)
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
@@ -57,6 +136,7 @@ Scans OpenClaw state for:
|
||||
- plaintext secret storage
|
||||
- unresolved 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)
|
||||
- legacy residues (legacy auth store entries, OAuth reminders)
|
||||
|
||||
@@ -75,8 +155,8 @@ Report shape:
|
||||
|
||||
- `status`: `clean | findings | unresolved`
|
||||
- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete`
|
||||
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
|
||||
- finding codes: `PLAINTEXT_FOUND`, `REF_UNRESOLVED`, `REF_SHADOWED`, `LEGACY_RESIDUE`
|
||||
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `storeResidueCount`, `legacyResidueCount`
|
||||
- finding codes: `PLAINTEXT_FOUND`, `REF_UNRESOLVED`, `REF_SHADOWED`, `STORE_PLAINTEXT_RESIDUE`, `LEGACY_RESIDUE`
|
||||
|
||||
## Configure (interactive helper)
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
attaching to either session.
|
||||
- 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).
|
||||
- With no explicit URL or port, `tui` follows the active local Gateway port
|
||||
recorded by the running Gateway. Explicit `--url`, `OPENCLAW_GATEWAY_URL`,
|
||||
|
||||
@@ -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 `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.
|
||||
- Empty or missing `apiKey`/`baseUrl` in `models.json` fall back to config `models.providers`.
|
||||
- Other provider fields refresh from config and normalized catalog data.
|
||||
|
||||
@@ -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.
|
||||
|
||||
- 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)
|
||||
|
||||
## Recommended setup: API key (any provider)
|
||||
|
||||
@@ -501,8 +501,8 @@ Configuring a custom/local provider `baseUrl` is also the narrow network trust d
|
||||
- Merge precedence for matching provider IDs:
|
||||
- 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.
|
||||
- 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 header values are refreshed from source markers (`secretref-env:ENV_VAR_NAME` for env refs, `secretref-managed` for file/exec refs).
|
||||
- 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/store refs).
|
||||
- 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 `contextTokens` follows the same explicit-wins-else-implicit rule; use it to limit effective context without changing native model metadata.
|
||||
|
||||
@@ -1131,7 +1131,7 @@ Secret refs are additive: plaintext values still work.
|
||||
Use one object shape:
|
||||
|
||||
```json5
|
||||
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
|
||||
{ source: "env" | "file" | "exec" | "store", provider: "default", id: "..." }
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
@@ -683,7 +683,7 @@ Rules:
|
||||
|
||||
</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:
|
||||
|
||||
```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).
|
||||
</Accordion>
|
||||
|
||||
|
||||
+60
-5
@@ -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:
|
||||
- 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
|
||||
- Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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`, `exec`, and `store` refs) using the same fail-fast gate.
|
||||
|
||||
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:
|
||||
|
||||
```json5
|
||||
{ source: "env" | "file" | "exec", provider: "default", id: "..." }
|
||||
{ source: "env" | "file" | "exec" | "store", provider: "default", id: "..." }
|
||||
```
|
||||
|
||||
<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)
|
||||
|
||||
</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>
|
||||
|
||||
## Provider config
|
||||
@@ -166,6 +179,7 @@ Define providers under `secrets.providers`:
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
teamstore: { source: "store" },
|
||||
filemain: {
|
||||
source: "file",
|
||||
path: "~/.openclaw/secrets.json",
|
||||
@@ -190,6 +204,7 @@ Define providers under `secrets.providers`:
|
||||
env: "default",
|
||||
file: "filemain",
|
||||
exec: "vault",
|
||||
store: "teamstore",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -247,6 +262,44 @@ but are not displayed because resolver output can contain credential material.
|
||||
|
||||
</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
|
||||
|
||||
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)
|
||||
- `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.
|
||||
|
||||
@@ -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.
|
||||
- Unresolved 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.
|
||||
|
||||
@@ -694,7 +749,7 @@ If you save a plan instead of applying during `configure`, apply that saved plan
|
||||
<Accordion title="secrets configure">
|
||||
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.
|
||||
- Can create a new auth-profile mapping directly in the target picker.
|
||||
- Captures SecretRef details (`source`, `provider`, `id`).
|
||||
|
||||
@@ -763,7 +763,7 @@ Also useful for backup decisions:
|
||||
|
||||
- WhatsApp: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- 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.*`)
|
||||
- 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`)
|
||||
|
||||
@@ -56,7 +56,7 @@ Route fields:
|
||||
| `controllerId` | no | `webhooks/<routeId>` | Used as the default `create_flow` controller. |
|
||||
| `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
|
||||
secret cannot resolve, the Gateway keeps running and that exact route stays
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
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:
|
||||
|
||||
@@ -133,6 +133,7 @@ The lists below are generated from the source target registry and checked agains
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -96,7 +96,7 @@ behavior and outputs, see [CLI setup reference](/start/wizard-cli-reference).
|
||||
- In token mode, interactive setup offers:
|
||||
- **Generate/store plaintext token** (default)
|
||||
- **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.
|
||||
- In password mode, interactive setup also supports plaintext or SecretRef storage.
|
||||
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@ Use this when debugging auth or deciding what to back up:
|
||||
|
||||
- **WhatsApp**: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
|
||||
- **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.*`)
|
||||
- **Pairing allowlists**:
|
||||
- `~/.openclaw/credentials/<channel>-allowFrom.json` (default account)
|
||||
|
||||
@@ -32,7 +32,7 @@ Add `--json` for a machine-readable summary.
|
||||
|
||||
- `--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.
|
||||
- `--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
|
||||
openclaw onboard --non-interactive --accept-risk \
|
||||
|
||||
@@ -303,7 +303,7 @@ Credential storage mode:
|
||||
- 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.
|
||||
- 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" }`.
|
||||
- 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).
|
||||
|
||||
@@ -161,7 +161,7 @@ Local mode (default) walks through these steps:
|
||||
tool policy strict - weaker or older tiers are easier to prompt-inject.
|
||||
For non-interactive runs, `--secret-input-mode ref` stores new credentials
|
||||
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
|
||||
env var. Previously stored plaintext is not migrated; see
|
||||
[Secrets management](/gateway/secrets). Interactive secret reference mode can
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["env", "file", "exec"]
|
||||
"enum": ["env", "file", "exec", "store"]
|
||||
},
|
||||
"provider": { "type": "string" },
|
||||
"id": { "type": "string" }
|
||||
|
||||
@@ -500,6 +500,9 @@ function secretRefDefaults(value: unknown): SecretRefDefaults | undefined {
|
||||
if (typeof value.exec === "string") {
|
||||
defaults.exec = value.exec;
|
||||
}
|
||||
if (typeof value.store === "string") {
|
||||
defaults.store = value.store;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ export type PolicySecretEvidence = {
|
||||
readonly kind: "input" | "provider";
|
||||
readonly source: string;
|
||||
readonly provenance?: "secretRef";
|
||||
readonly refSource?: "env" | "file" | "exec";
|
||||
readonly refSource?: "env" | "file" | "exec" | "store";
|
||||
readonly refProvider?: string;
|
||||
readonly providerSource?: string;
|
||||
readonly insecure?: readonly string[];
|
||||
@@ -236,7 +236,7 @@ export type PolicyDataHandlingEvidence = {
|
||||
};
|
||||
|
||||
export type SecretRefEvidence = {
|
||||
readonly source: "env" | "file" | "exec";
|
||||
readonly source: "env" | "file" | "exec" | "store";
|
||||
readonly provider: string;
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ type QaAuthProfileCredential =
|
||||
};
|
||||
|
||||
type QaSecretRef = {
|
||||
source: "env" | "file" | "exec";
|
||||
source: "env" | "file" | "exec" | "store";
|
||||
provider?: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["env", "file", "exec"]
|
||||
"enum": ["env", "file", "exec", "store"]
|
||||
},
|
||||
"provider": { "type": "string" },
|
||||
"id": { "type": "string" }
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["env", "file", "exec"]
|
||||
"enum": ["env", "file", "exec", "store"]
|
||||
},
|
||||
"provider": { "type": "string" },
|
||||
"id": { "type": "string" }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { normalizeWebhookPath } from "../runtime-api.js";
|
||||
|
||||
const secretRefSchema = z
|
||||
.object({
|
||||
source: z.enum(["env", "file", "exec"]),
|
||||
source: z.enum(["env", "file", "exec", "store"]),
|
||||
provider: z.string().trim().min(1),
|
||||
id: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
@@ -27,6 +27,9 @@ describe("gateway protocol SecretRef schema", () => {
|
||||
id: "/providers/openai/apiKey",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateSecretRef.Check({ source: "store", provider: "default", id: "STORED_API_KEY" }),
|
||||
).toBe(true);
|
||||
for (const id of VALID_EXEC_SECRET_REF_IDS) {
|
||||
expect(validateSecretRef.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);
|
||||
}
|
||||
});
|
||||
|
||||
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 }),
|
||||
});
|
||||
|
||||
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. */
|
||||
export const SecretRefSchema = Type.Union([
|
||||
EnvSecretRefSchema,
|
||||
FileSecretRefSchema,
|
||||
ExecSecretRefSchema,
|
||||
StoreSecretRefSchema,
|
||||
]);
|
||||
|
||||
/** 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. */
|
||||
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}$/;
|
||||
/** JSON-schema fragment that rejects absolute file secret ref ids. */
|
||||
export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
/** Supported secret reference backing stores. */
|
||||
type SecretRefSource = "env" | "file" | "exec";
|
||||
type SecretRefSource = "env" | "file" | "exec" | "store";
|
||||
|
||||
/** Canonical secret reference shape used after gateway resolution. */
|
||||
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 LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:";
|
||||
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. */
|
||||
function hasSecretRefSource(value: unknown): value is SecretRefSource {
|
||||
|
||||
@@ -11,6 +11,20 @@ const cfg = {
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
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", () => {
|
||||
expect(
|
||||
resolveStoredCredentialReadOnlyAvailability({
|
||||
|
||||
@@ -42,12 +42,11 @@ export function resolveSecretRefReadOnlyAvailability(
|
||||
return false;
|
||||
}
|
||||
const source = cfg.secrets?.providers?.[value.provider];
|
||||
if (
|
||||
(!source &&
|
||||
(value.source !== "env" ||
|
||||
value.provider !== resolveDefaultSecretProviderAlias(cfg, "env"))) ||
|
||||
(source && source.source !== value.source)
|
||||
) {
|
||||
const isImplicitProvider =
|
||||
(value.source === "env" && value.provider === resolveDefaultSecretProviderAlias(cfg, "env")) ||
|
||||
(value.source === "store" &&
|
||||
value.provider === resolveDefaultSecretProviderAlias(cfg, "store"));
|
||||
if ((!source && !isImplicitProvider) || (source && source.source !== value.source)) {
|
||||
return false;
|
||||
}
|
||||
if (value.source === "env") {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
|
||||
import { isInstalledPluginEnabled } from "../../plugins/installed-plugin-index.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";
|
||||
|
||||
const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
@@ -27,7 +28,7 @@ const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^(GH|GITHUB)_TOKEN$/i,
|
||||
/^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i,
|
||||
/_ADMIN_KEY$/i,
|
||||
/_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i,
|
||||
SECRET_ENV_NAME_RE,
|
||||
];
|
||||
|
||||
const ALLOWED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
|
||||
@@ -91,10 +91,10 @@ export function configPatchModeError(message: string): Error {
|
||||
|
||||
function parseSecretRefSource(raw: string, label: string): SecretRefSource {
|
||||
const source = raw.trim();
|
||||
if (source === "env" || source === "file" || source === "exec") {
|
||||
if (source === "env" || source === "file" || source === "exec" || source === "store") {
|
||||
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: {
|
||||
@@ -121,6 +121,11 @@ function parseSecretRefBuilder(params: {
|
||||
if (source === "env" && !isValidEnvSecretRefId(id)) {
|
||||
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)) {
|
||||
throw new Error(
|
||||
`${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 } : {}),
|
||||
...(maxBytes !== undefined ? { maxBytes } : {}),
|
||||
};
|
||||
} else if (source === "store") {
|
||||
provider = { source: "store" };
|
||||
} else {
|
||||
const command = opts.providerCommand?.trim();
|
||||
if (!command) {
|
||||
@@ -418,7 +425,7 @@ function buildSingleSetOperations(params: {
|
||||
}
|
||||
if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) {
|
||||
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 [
|
||||
|
||||
@@ -435,9 +435,9 @@ export function registerConfigCli(program: Command) {
|
||||
false,
|
||||
)
|
||||
.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("--provider-source <source>", "Provider builder: source (env|file|exec)")
|
||||
.option("--provider-source <source>", "Provider builder: source (env|file|exec|store)")
|
||||
.option(
|
||||
"--provider-allowlist <envVar>",
|
||||
"Provider builder (env): allowlist entry (repeatable)",
|
||||
|
||||
@@ -85,6 +85,7 @@ const JSON_NOT_APPLICABLE = {
|
||||
"directory groups",
|
||||
"security",
|
||||
"secrets",
|
||||
"secrets store",
|
||||
"models aliases",
|
||||
"models fallbacks",
|
||||
"models image-fallbacks",
|
||||
@@ -197,6 +198,9 @@ const JSON_NOT_APPLICABLE = {
|
||||
"channels remove",
|
||||
"channels login",
|
||||
"channels logout",
|
||||
"secrets store set",
|
||||
"secrets store rm",
|
||||
"secrets store import",
|
||||
],
|
||||
},
|
||||
rawArtifacts: {
|
||||
|
||||
@@ -240,6 +240,7 @@ describe("secrets CLI", () => {
|
||||
plaintextCount: 1,
|
||||
unresolvedRefCount: 0,
|
||||
shadowedRefCount: 0,
|
||||
storeResidueCount: 0,
|
||||
legacyResidueCount: 0,
|
||||
},
|
||||
resolution: {
|
||||
@@ -271,6 +272,7 @@ describe("secrets CLI", () => {
|
||||
plaintextCount: 0,
|
||||
unresolvedRefCount: 0,
|
||||
shadowedRefCount: 0,
|
||||
storeResidueCount: 0,
|
||||
legacyResidueCount: 0,
|
||||
},
|
||||
resolution: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { formatGatewayCommandFailure } from "./error-format.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
import { registerSecretStoreCli } from "./secrets-store-cli.js";
|
||||
|
||||
type FsModule = typeof import("node:fs");
|
||||
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`,
|
||||
);
|
||||
|
||||
registerSecretStoreCli(secrets);
|
||||
|
||||
addGatewayClientOptions(
|
||||
secrets
|
||||
.command("reload")
|
||||
@@ -174,7 +177,7 @@ export function registerSecretsCli(program: Command): void {
|
||||
defaultRuntime.writeJson(report);
|
||||
} else {
|
||||
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) {
|
||||
for (const finding of report.findings.slice(0, 20)) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -37,7 +37,9 @@ describe("config secret refs schema", () => {
|
||||
command: "/usr/local/bin/openclaw-secret-resolver",
|
||||
args: ["resolve"],
|
||||
},
|
||||
store: { source: "store" },
|
||||
},
|
||||
defaults: { store: "store" },
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
@@ -46,6 +48,11 @@ describe("config secret refs schema", () => {
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
models: {
|
||||
|
||||
@@ -89,6 +89,27 @@ function collectMetadataOnlyCompositionBranches(
|
||||
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", () => {
|
||||
it("is deterministic for a fixed generatedAt timestamp", () => {
|
||||
expect(
|
||||
@@ -172,6 +193,13 @@ describe("base config schema", () => {
|
||||
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", () => {
|
||||
for (const path of [
|
||||
["agents", "defaults", "sandbox", "docker", "setupCommand"],
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Verifies secret config type guards and normalization helpers.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectEnvSecretRefIds, parseEnvTemplateSecretRef } from "./types.secrets.js";
|
||||
import {
|
||||
coerceSecretRef,
|
||||
collectEnvSecretRefIds,
|
||||
parseEnvTemplateSecretRef,
|
||||
} from "./types.secrets.js";
|
||||
|
||||
describe("parseEnvTemplateSecretRef", () => {
|
||||
it("parses ${VAR} template syntax", () => {
|
||||
@@ -56,3 +60,11 @@ describe("collectEnvSecretRefIds", () => {
|
||||
).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
@@ -1,9 +1,9 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
// Defines secret reference and resolution configuration types.
|
||||
import { isRecord } from "../utils.js";
|
||||
|
||||
/** 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.
|
||||
@@ -11,6 +11,7 @@ export type SecretRefSource = "env" | "file" | "exec"; // pragma: allowlist secr
|
||||
* - env source: provider "default", id "OPENAI_API_KEY"
|
||||
* - file source: provider "mounted-json", id "/providers/openai/apiKey"
|
||||
* - exec source: provider "vault", id "openai/api-key"
|
||||
* - store source: provider "default", id "OPENAI_API_KEY"
|
||||
*/
|
||||
export type SecretRef = {
|
||||
source: SecretRefSource;
|
||||
@@ -44,6 +45,8 @@ type SecretDefaults = {
|
||||
file?: string;
|
||||
/** Default provider alias for exec SecretRefs. */
|
||||
exec?: string;
|
||||
/** Default provider alias for shared-store SecretRefs. */
|
||||
store?: string;
|
||||
};
|
||||
|
||||
/** 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 (
|
||||
(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" &&
|
||||
value.provider.trim().length > 0 &&
|
||||
typeof value.id === "string" &&
|
||||
@@ -75,7 +81,10 @@ function isLegacySecretRefWithoutProvider(
|
||||
return false;
|
||||
}
|
||||
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" &&
|
||||
value.id.trim().length > 0 &&
|
||||
value.provider === undefined
|
||||
@@ -171,12 +180,7 @@ export function coerceSecretRef(value: unknown, defaults?: SecretDefaults): Secr
|
||||
return value;
|
||||
}
|
||||
if (isLegacySecretRefWithoutProvider(value)) {
|
||||
const provider =
|
||||
value.source === "env"
|
||||
? (defaults?.env ?? DEFAULT_SECRET_PROVIDER_ALIAS)
|
||||
: value.source === "file"
|
||||
? (defaults?.file ?? DEFAULT_SECRET_PROVIDER_ALIAS)
|
||||
: (defaults?.exec ?? DEFAULT_SECRET_PROVIDER_ALIAS);
|
||||
const provider = defaults?.[value.source] ?? DEFAULT_SECRET_PROVIDER_ALIAS;
|
||||
return {
|
||||
source: value.source,
|
||||
provider,
|
||||
@@ -369,10 +373,15 @@ export type ExecSecretProviderConfig =
|
||||
| ManualExecSecretProviderConfig
|
||||
| PluginIntegrationSecretProviderConfig;
|
||||
|
||||
export type StoreSecretProviderConfig = {
|
||||
source: "store";
|
||||
};
|
||||
|
||||
export type SecretProviderConfig =
|
||||
| EnvSecretProviderConfig
|
||||
| FileSecretProviderConfig
|
||||
| ExecSecretProviderConfig;
|
||||
| ExecSecretProviderConfig
|
||||
| StoreSecretProviderConfig;
|
||||
|
||||
export type SecretsConfig = {
|
||||
providers?: Record<string, SecretProviderConfig>;
|
||||
@@ -380,5 +389,6 @@ export type SecretsConfig = {
|
||||
env?: string;
|
||||
file?: string;
|
||||
exec?: string;
|
||||
store?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
formatExecSecretRefIdValidationMessage,
|
||||
isValidExecSecretRefId,
|
||||
isValidFileSecretRefId,
|
||||
SECRET_PROVIDER_ALIAS_PATTERN,
|
||||
} from "../secrets/ref-contract.js";
|
||||
import type { ModelCompatConfig } 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 { 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_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
||||
|
||||
@@ -41,7 +41,7 @@ const EnvSecretRefSchema = z
|
||||
id: z
|
||||
.string()
|
||||
.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").',
|
||||
),
|
||||
})
|
||||
@@ -78,11 +78,30 @@ const ExecSecretRefSchema = z
|
||||
})
|
||||
.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. */
|
||||
export const SecretRefSchema = z.discriminatedUnion("source", [
|
||||
EnvSecretRefSchema,
|
||||
FileSecretRefSchema,
|
||||
ExecSecretRefSchema,
|
||||
StoreSecretRefSchema,
|
||||
]);
|
||||
|
||||
/** Accepts either legacy inline secret strings or structured secret references. */
|
||||
@@ -101,7 +120,7 @@ export const SsrFPolicyConfigSchema = z
|
||||
const SecretsEnvProviderSchema = z
|
||||
.object({
|
||||
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();
|
||||
|
||||
@@ -142,7 +161,7 @@ const SecretsManualExecProviderSchema = z
|
||||
.optional(),
|
||||
jsonOnly: z.boolean().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
|
||||
.array(
|
||||
z
|
||||
@@ -172,11 +191,14 @@ const SecretsExecProviderSchema = z.union([
|
||||
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([
|
||||
SecretsEnvProviderSchema,
|
||||
SecretsFileProviderSchema,
|
||||
SecretsExecProviderSchema,
|
||||
SecretsStoreProviderSchema,
|
||||
]);
|
||||
|
||||
/** 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(),
|
||||
file: 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()
|
||||
.optional(),
|
||||
|
||||
@@ -210,7 +210,9 @@ export async function deliverOutboundPayloadsWithQueueCleanup(
|
||||
},
|
||||
onPlatformSendDispatch: async () => {
|
||||
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 {
|
||||
if (producerClaimId) {
|
||||
await markDeliveryPlatformSendDispatched(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} 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.
|
||||
*/
|
||||
export function buildSecretInputSchema() {
|
||||
@@ -48,6 +48,18 @@ const secretInputSchema = z
|
||||
),
|
||||
})
|
||||
.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
|
||||
.object({
|
||||
source: z.literal("file"),
|
||||
|
||||
@@ -51,6 +51,9 @@ describe("plugin-sdk secret input schema", () => {
|
||||
schema.safeParse({ source: "file", provider: "filemain", id: "/providers/openai/apiKey" })
|
||||
.success,
|
||||
).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) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects store refs outside the env-name grammar", () => {
|
||||
expect(
|
||||
schema.safeParse({ source: "store", provider: "default", id: "lowercase" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
@@ -25,7 +25,7 @@ function loadSecretResolve() {
|
||||
|
||||
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. */
|
||||
export type SecretRefSetupPromptCopy = {
|
||||
@@ -36,7 +36,11 @@ export type SecretRefSetupPromptCopy = {
|
||||
envVarMissingError?: (envVar: string) => string;
|
||||
noProvidersMessage?: 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. */
|
||||
@@ -162,12 +166,13 @@ async function promptProviderSecretRefForSetup(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
|
||||
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) {
|
||||
await params.prompter.note(
|
||||
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",
|
||||
);
|
||||
throw new Error("retry");
|
||||
@@ -184,13 +189,23 @@ async function promptProviderSecretRefForSetup(params: {
|
||||
options: externalProviders.map(([providerName, provider]) => ({
|
||||
value: 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];
|
||||
if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) {
|
||||
if (
|
||||
!providerEntry ||
|
||||
(providerEntry.source !== "file" &&
|
||||
providerEntry.source !== "exec" &&
|
||||
providerEntry.source !== "store")
|
||||
) {
|
||||
await params.prompter.note(
|
||||
`Provider "${selectedProvider}" is not a file/exec provider.`,
|
||||
`Provider "${selectedProvider}" is not a file/exec/store provider.`,
|
||||
"Invalid provider",
|
||||
);
|
||||
throw new Error("retry");
|
||||
@@ -199,17 +214,26 @@ async function promptProviderSecretRefForSetup(params: {
|
||||
const idPrompt =
|
||||
providerEntry.source === "file"
|
||||
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
|
||||
: providerEntry.source === "store"
|
||||
? "Secret store name"
|
||||
: "Secret id for the exec provider";
|
||||
const idDefault =
|
||||
providerEntry.source === "file"
|
||||
? providerEntry.mode === "singleValue"
|
||||
? "value"
|
||||
: params.defaultFilePointer
|
||||
: providerEntry.source === "store"
|
||||
? (resolveDefaultProviderEnvVar(params.provider, params.config) ?? "")
|
||||
: `${params.provider}/apiKey`;
|
||||
const idRaw = await params.prompter.text({
|
||||
message: idPrompt,
|
||||
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) => {
|
||||
const candidate = value.trim();
|
||||
if (!candidate) {
|
||||
@@ -232,6 +256,9 @@ async function promptProviderSecretRefForSetup(params: {
|
||||
if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) {
|
||||
return formatExecSecretRefIdValidationMessage();
|
||||
}
|
||||
if (providerEntry.source === "store" && !isValidEnvSecretRefId(candidate)) {
|
||||
return 'Use a store name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -290,14 +317,20 @@ export async function promptSecretRefForSetup(params: {
|
||||
label: "Environment variable",
|
||||
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",
|
||||
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;
|
||||
|
||||
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 {
|
||||
return await promptProviderSecretRefForSetup({
|
||||
provider: params.provider,
|
||||
|
||||
@@ -54,7 +54,7 @@ export type ProviderAuthContext = {
|
||||
* Onboarding secret persistence preference.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
secretInputMode?: SecretInputMode;
|
||||
|
||||
@@ -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.`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
writePersistedAuthProfileStoreRaw,
|
||||
} from "../agents/auth-profiles/sqlite.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { runSecretsAudit } from "./audit.js";
|
||||
import { writeSecretStoreEntry } from "./store/secret-store.js";
|
||||
|
||||
type AuditFixture = {
|
||||
rootDir: string;
|
||||
@@ -221,6 +223,7 @@ describe("secrets audit", () => {
|
||||
await runSecretsAudit({ env: warmFixture.env });
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await fs.rm(warmFixture.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -266,6 +269,7 @@ describe("secrets audit", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
await fs.rm(fixture.rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -279,6 +283,51 @@ describe("secrets audit", () => {
|
||||
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 () => {
|
||||
await writeJsonFile(fixture.authJsonPath, {
|
||||
openai: {
|
||||
|
||||
+38
-15
@@ -20,6 +20,8 @@ import { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.j
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { resolveUserPath } from "../utils.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 { createSecretsConfigIO } from "./config-io.js";
|
||||
import { getSkippedExecRefStaticError, selectRefsForExecPolicy } from "./exec-resolution-policy.js";
|
||||
@@ -47,7 +49,12 @@ import {
|
||||
import { discoverConfigSecretTargets } from "./target-registry.js";
|
||||
|
||||
/** 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. */
|
||||
type SecretsAuditSeverity = "info" | "warn" | "error"; // pragma: allowlist secret
|
||||
@@ -80,6 +87,7 @@ type SecretsAuditReport = {
|
||||
plaintextCount: number;
|
||||
unresolvedRefCount: number;
|
||||
shadowedRefCount: number;
|
||||
storeResidueCount: number;
|
||||
legacyResidueCount: number;
|
||||
};
|
||||
findings: SecretsAuditFinding[];
|
||||
@@ -109,6 +117,7 @@ type AuditCollector = {
|
||||
refAssignments: RefAssignment[];
|
||||
configProviderRefPaths: Map<string, string[]>;
|
||||
authProviderState: Map<string, ProviderAuthState>;
|
||||
configPlaintextAssignments: PlaintextAssignment[];
|
||||
filesScanned: Set<string>;
|
||||
};
|
||||
|
||||
@@ -196,6 +205,24 @@ function collectConfigSecrets(params: {
|
||||
refValue: target.refValue,
|
||||
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) {
|
||||
params.collector.refAssignments.push({
|
||||
file: params.configPath,
|
||||
@@ -210,21 +237,10 @@ function collectConfigSecrets(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasPlaintext = hasConfiguredPlaintextSecretValue(
|
||||
target.value,
|
||||
target.entry.expectedResolvedValue,
|
||||
);
|
||||
if (
|
||||
target.entry.id === "models.providers.*.headers.*" &&
|
||||
!isLikelySensitiveModelProviderHeaderName(target.pathSegments.at(-1) ?? "")
|
||||
) {
|
||||
if (isNonSecretHeader) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
target.entry.id === "models.providers.*.apiKey" &&
|
||||
typeof target.value === "string" &&
|
||||
isNonSecretApiKeyMarker(target.value)
|
||||
) {
|
||||
if (isModelMarker) {
|
||||
continue;
|
||||
}
|
||||
if (!hasPlaintext) {
|
||||
@@ -612,11 +628,11 @@ function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport[
|
||||
plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length,
|
||||
unresolvedRefCount: findings.filter((entry) => entry.code === "REF_UNRESOLVED").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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs local storage/config audit and returns a structured report. */
|
||||
/** Runs a secrets audit over config/auth stores and returns structured findings. */
|
||||
export async function runSecretsAudit(
|
||||
params: {
|
||||
@@ -636,6 +652,7 @@ export async function runSecretsAudit(
|
||||
refAssignments: [],
|
||||
configProviderRefPaths: new Map(),
|
||||
authProviderState: new Map(),
|
||||
configPlaintextAssignments: [],
|
||||
filesScanned: new Set([configPath]),
|
||||
};
|
||||
|
||||
@@ -679,6 +696,12 @@ export async function runSecretsAudit(
|
||||
resolvabilityComplete: unresolvedRefResult.skippedExecRefs === 0,
|
||||
};
|
||||
collectShadowingFindings(collector);
|
||||
collector.findings.push(
|
||||
...findSecretStorePlaintextResidueFindings({
|
||||
assignments: collector.configPlaintextAssignments,
|
||||
database: { env },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
addFinding(collector, {
|
||||
code: "REF_UNRESOLVED",
|
||||
|
||||
+26
-11
@@ -12,11 +12,12 @@ import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
|
||||
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js";
|
||||
import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type {
|
||||
ManualExecSecretProviderConfig,
|
||||
SecretProviderConfig,
|
||||
SecretRef,
|
||||
SecretRefSource,
|
||||
import {
|
||||
isValidEnvSecretRefId,
|
||||
type ManualExecSecretProviderConfig,
|
||||
type SecretProviderConfig,
|
||||
type SecretRef,
|
||||
type SecretRefSource,
|
||||
} from "../config/types.secrets.js";
|
||||
import { isSafeExecutableValue } from "../infra/exec-safety.js";
|
||||
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
@@ -54,7 +55,6 @@ type SecretsConfigureResult = {
|
||||
preflight: SecretsApplyResult;
|
||||
};
|
||||
|
||||
const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
|
||||
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
||||
|
||||
@@ -131,11 +131,15 @@ function removeSecretProvider(config: OpenClawConfig, providerAlias: string): bo
|
||||
if (defaults?.exec === providerAlias) {
|
||||
delete defaults.exec;
|
||||
}
|
||||
if (defaults?.store === providerAlias) {
|
||||
delete defaults.store;
|
||||
}
|
||||
if (
|
||||
defaults &&
|
||||
defaults.env === undefined &&
|
||||
defaults.file === undefined &&
|
||||
defaults.exec === undefined
|
||||
defaults.exec === undefined &&
|
||||
defaults.store === undefined
|
||||
) {
|
||||
delete config.secrets?.defaults;
|
||||
}
|
||||
@@ -150,6 +154,9 @@ function providerHint(provider: SecretProviderConfig): string {
|
||||
if (provider.source === "file") {
|
||||
return `file (${provider.mode ?? "json"})`;
|
||||
}
|
||||
if (provider.source === "store") {
|
||||
return "store";
|
||||
}
|
||||
if ("pluginIntegration" in provider) {
|
||||
const { pluginId, integrationId } = provider.pluginIntegration;
|
||||
return `exec plugin (${pluginId}:${integrationId})`;
|
||||
@@ -188,6 +195,7 @@ function toSourceChoices(config: OpenClawConfig): Array<{ value: SecretRefSource
|
||||
value: "env",
|
||||
label: "env",
|
||||
},
|
||||
{ value: "store", label: "store" },
|
||||
];
|
||||
if (hasSource("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 {
|
||||
const entries = parseCsv(value);
|
||||
for (const entry of entries) {
|
||||
if (!ENV_NAME_PATTERN.test(entry)) {
|
||||
if (!isValidEnvSecretRefId(entry)) {
|
||||
return `Invalid env name: ${entry}`;
|
||||
}
|
||||
}
|
||||
@@ -422,6 +430,7 @@ async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRe
|
||||
{ value: "env", label: "env" },
|
||||
{ value: "file", label: "file" },
|
||||
{ value: "exec", label: "exec" },
|
||||
{ value: "store", label: "store" },
|
||||
],
|
||||
initialValue: initial,
|
||||
}),
|
||||
@@ -629,6 +638,9 @@ async function promptProviderConfig(
|
||||
if (source === "file") {
|
||||
return await promptFileProvider(current?.source === "file" ? current : undefined);
|
||||
}
|
||||
if (source === "store") {
|
||||
return { source: "store" };
|
||||
}
|
||||
return await promptExecProvider(
|
||||
current?.source === "exec" && "command" in current ? current : undefined,
|
||||
);
|
||||
@@ -653,7 +665,7 @@ async function configureProvidersInteractive(
|
||||
{
|
||||
value: "add",
|
||||
label: "Add provider",
|
||||
hint: "Define a new env/file/exec provider",
|
||||
hint: "Define a new env/file/exec/store provider",
|
||||
},
|
||||
];
|
||||
if (presetEntries.length > 0) {
|
||||
@@ -686,7 +698,7 @@ async function configureProvidersInteractive(
|
||||
message:
|
||||
providerEntries.length > 0
|
||||
? "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,
|
||||
}),
|
||||
"Secrets configure cancelled.",
|
||||
@@ -960,7 +972,7 @@ export async function runSecretsConfigureInteractive(
|
||||
const suggestedIdFromExistingRef =
|
||||
existingRef?.source === source ? existingRef.id : undefined;
|
||||
let suggestedId = suggestedIdFromExistingRef;
|
||||
if (!suggestedId && source === "env") {
|
||||
if (!suggestedId && (source === "env" || source === "store")) {
|
||||
suggestedId = resolveSuggestedEnvSecretId(candidate);
|
||||
}
|
||||
if (!suggestedId && source === "file") {
|
||||
@@ -978,6 +990,9 @@ export async function runSecretsConfigureInteractive(
|
||||
if (!trimmed) {
|
||||
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)) {
|
||||
return formatExecSecretRefIdValidationMessage();
|
||||
}
|
||||
|
||||
+4
-1
@@ -130,7 +130,10 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
|
||||
!resolved ||
|
||||
!ref ||
|
||||
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" ||
|
||||
ref.provider.trim().length === 0 ||
|
||||
typeof ref.id !== "string" ||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isValidExecSecretRefId,
|
||||
isValidFileSecretRefId,
|
||||
isValidSecretRef,
|
||||
resolveDefaultSecretProviderAlias,
|
||||
validateExecSecretRefId,
|
||||
} from "./ref-contract.js";
|
||||
|
||||
@@ -68,4 +69,14 @@ describe("secret ref validation", () => {
|
||||
} as never),
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
*/
|
||||
|
||||
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}$/;
|
||||
const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$/;
|
||||
|
||||
@@ -43,6 +43,8 @@ type SecretRefDefaultsCarrier = {
|
||||
file?: string;
|
||||
/** Default provider alias for exec-backed secret refs. */
|
||||
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. */
|
||||
providers?: Record<string, { source?: string }>;
|
||||
@@ -60,12 +62,7 @@ export function resolveDefaultSecretProviderAlias(
|
||||
source: SecretRefSource,
|
||||
options?: { preferFirstProviderForSource?: boolean },
|
||||
): string {
|
||||
const configured =
|
||||
source === "env"
|
||||
? config.secrets?.defaults?.env
|
||||
: source === "file"
|
||||
? config.secrets?.defaults?.file
|
||||
: config.secrets?.defaults?.exec;
|
||||
const configured = config.secrets?.defaults?.[source];
|
||||
if (configured?.trim()) {
|
||||
return configured.trim();
|
||||
}
|
||||
@@ -141,6 +138,9 @@ export function isValidSecretRef(ref: SecretRef): boolean {
|
||||
if (ref.source === "file") {
|
||||
return isValidFileSecretRefId(ref.id);
|
||||
}
|
||||
if (ref.source === "store") {
|
||||
return isValidEnvSecretRefId(ref.id);
|
||||
}
|
||||
return isValidExecSecretRefId(ref.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
providerResolutionError,
|
||||
refResolutionError,
|
||||
} from "./resolve-errors.js";
|
||||
import { resolveStoreRefs } from "./resolve-store.js";
|
||||
import type { SecretRefResolveCache } from "./resolve-types.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
@@ -160,6 +161,12 @@ function resolveConfiguredProvider(params: {
|
||||
if (ref.source === "env" && ref.provider === resolveDefaultSecretProviderAlias(config, "env")) {
|
||||
return { source: "env" };
|
||||
}
|
||||
if (
|
||||
ref.source === "store" &&
|
||||
ref.provider === resolveDefaultSecretProviderAlias(config, "store")
|
||||
) {
|
||||
return { source: "store" };
|
||||
}
|
||||
throw providerResolutionError({
|
||||
code: "SECRET_PROVIDER_NOT_CONFIGURED",
|
||||
source: ref.source,
|
||||
@@ -675,6 +682,13 @@ async function resolveProviderRefs(params: {
|
||||
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 (isPluginIntegrationSecretProviderConfig(params.providerConfig)) {
|
||||
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}).`,
|
||||
);
|
||||
}
|
||||
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)) {
|
||||
throw new Error(
|
||||
`${formatExecSecretRefIdValidationMessage()} (ref: ${ref.source}:${ref.provider}:${id}).`,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export const FIRST_USE_STATE_INDEXES = ["execution_identity_contexts_run_created
|
||||
export const LAZY_ADDITIVE_STATE_TABLES = [
|
||||
...FIRST_USE_STATE_TABLES,
|
||||
"model_catalog_remote",
|
||||
"secret_store_entries",
|
||||
"gateway_origin_device_tokens",
|
||||
"sidebar_sections",
|
||||
"skill_workshop_proposal_events",
|
||||
@@ -22,7 +23,10 @@ export const LAZY_ADDITIVE_STATE_TABLES = [
|
||||
"skill_workshop_proposals",
|
||||
"worker_environment_ssh_fallback_ports",
|
||||
] 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. */
|
||||
export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
||||
/** User-facing guide for schema refusals; lives here so error sites avoid import cycles. */
|
||||
|
||||
+13
@@ -1055,6 +1055,18 @@ export interface SchemaMeta {
|
||||
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 {
|
||||
created_at: number;
|
||||
name: string;
|
||||
@@ -1657,6 +1669,7 @@ export interface DB {
|
||||
plugin_state_entries: PluginStateEntries;
|
||||
sandbox_registry_entries: SandboxRegistryEntries;
|
||||
schema_meta: SchemaMeta;
|
||||
secret_store_entries: SecretStoreEntries;
|
||||
session_groups: SessionGroups;
|
||||
session_state_events: SessionStateEvents;
|
||||
session_state_heads: SessionStateHeads;
|
||||
|
||||
@@ -2238,3 +2238,21 @@ CREATE TABLE IF NOT EXISTS model_catalog_remote (
|
||||
last_modified TEXT,
|
||||
checked_at INTEGER NOT NULL
|
||||
) 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;
|
||||
|
||||
@@ -18,7 +18,7 @@ export type SystemAgentOperation =
|
||||
| {
|
||||
kind: "config-set-ref";
|
||||
path: string;
|
||||
source: "env" | "file" | "exec";
|
||||
source: "env" | "file" | "exec" | "store";
|
||||
id: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ const CONFIG_SCHEMA_RE = new RegExp(
|
||||
"i",
|
||||
);
|
||||
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",
|
||||
);
|
||||
const SETUP_RE = new RegExp(
|
||||
@@ -194,7 +194,7 @@ export function parseSystemAgentOperation(input: string): SystemAgentOperation {
|
||||
return {
|
||||
kind: "config-set-ref",
|
||||
path: configSetRefMatch.groups.path,
|
||||
source: source as "env" | "file" | "exec",
|
||||
source: source as "env" | "file" | "exec" | "store",
|
||||
id: configSetRefMatch.groups.id.trim(),
|
||||
...(configSetRefMatch.groups.provider ? { provider: configSetRefMatch.groups.provider } : {}),
|
||||
};
|
||||
|
||||
@@ -285,6 +285,14 @@ describe("parseSystemAgentOperation", () => {
|
||||
source: "env",
|
||||
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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -154,7 +154,8 @@ function parsePendingOperation(value: unknown): SystemAgentOperation | null {
|
||||
!isNonEmptyString(operation.path) ||
|
||||
(operation.source !== "env" &&
|
||||
operation.source !== "file" &&
|
||||
operation.source !== "exec") ||
|
||||
operation.source !== "exec" &&
|
||||
operation.source !== "store") ||
|
||||
!isNonEmptyString(operation.id) ||
|
||||
!hasOptionalString(operation, "provider")
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user