-
-
Notifications
You must be signed in to change notification settings - Fork 69.3k
normalizeProviders leaks plaintext API keys into models.json for exec-source SecretRefs #34335
Description
Summary
models.json is an auto-generated runtime file rewritten on every gateway start. When a provider's API key is managed via an exec-source SecretRef in auth-profiles.json (e.g. macOS Keychain), the plaintext key can end up persisted in models.json — defeating the purpose of SecretRefs entirely.
The behavior is also inconsistent across providers: for OpenAI the plaintext key is written; for Google (using the identical secrets mechanism) it is not.
Reproduction
- Configure two providers with exec-source SecretRefs in
auth-profiles.json:
"openai:default": {
"type": "api_key",
"provider": "openai",
"keyRef": { "source": "exec", "provider": "keychain_openai", "id": "value" }
},
"google:default": {
"type": "api_key",
"provider": "google",
"keyRef": { "source": "exec", "provider": "keychain_google", "id": "google/apiKey" }
}- Restart the gateway.
- Inspect
~/.openclaw/agents/main/agent/models.json:- OpenAI:
"apiKey": "sk-proj-..."(plaintext on disk) - Google: no entry at all
- OpenAI:
Root cause
Two compounding issues in auth-profiles and models-config:
1. resolveApiKeyFromProfiles silently drops exec-source SecretRefs
This synchronous function is used during models.json generation. For api_key-type profiles:
if (cred.key?.trim()) return cred.key; // inline plaintext → returns it
const keyRef = coerceSecretRef(cred.keyRef);
if (keyRef?.source === "env") return keyRef.id; // env ref → returns var name
continue; // exec/keychain ref → silently skippedOnly inline keys and env-source refs are handled. Exec/keychain refs are silently ignored, producing undefined. This means the function cannot surface exec-backed credentials during models.json generation.
2. Merge logic in ensureOpenClawModelsJson creates permanent plaintext key persistence
if (typeof existing.apiKey === "string" && existing.apiKey)
preserved.apiKey = existing.apiKey;Once a plaintext key appears in models.json (from any historical source — an older code version, a migration, a previous explicit config entry), the merge logic preserves it on every subsequent gateway restart. The key is never re-evaluated against the current auth source.
How these interact
- If a provider's plaintext key was ever written to
models.json(e.g. via an older code path or explicit config), the merge logic preserves it forever — even after the user migrates to SecretRefs. - If a provider never had a plaintext key in
models.json, the current code cannot introduce one (since exec refs are dropped). This is why Google is clean and OpenAI is not.
Impact
- SecretRef security model is defeated: plaintext API key sitting on disk in
models.json(mode 0600, but still plaintext at rest) - Key rotation breaks silently: rotating the key in Keychain has no effect —
models.jsonkeeps the stale old key via merge - Inconsistent behavior: identical auth setup produces different persistence depending on file history
Suggested fix
normalizeProvidersshould never write resolved plaintext keys tomodels.jsonwhen the auth source is a SecretRef. Either omit theapiKeyfield entirely (let the async runtime path resolve it), or write a non-secret marker/reference.- The merge logic should not unconditionally preserve
apiKeystrings from existingmodels.jsonwhen the canonical auth source is a SecretRef inauth-profiles.json. The SecretRef should take precedence. resolveApiKeyFromProfilesshould be aware of exec-source refs — at minimum by returning a sentinel or marker instead of silently returningundefined.
Workaround
Manually remove the apiKey field from the affected provider entry in models.json. The async runtime path (resolveApiKeyForProvider → resolveApiKeyForProfile → resolveProfileSecretString) properly resolves exec SecretRefs at request time.
Environment
- OpenClaw 2026.3.1 (2a8ac97)
- macOS (Apple Silicon)
- Both providers using exec-source SecretRefs backed by macOS Keychain