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
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 skipped
Only 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.json keeps the stale old key via merge
- Inconsistent behavior: identical auth setup produces different persistence depending on file history
Suggested fix
normalizeProviders should never write resolved plaintext keys to models.json when the auth source is a SecretRef. Either omit the apiKey field entirely (let the async runtime path resolve it), or write a non-secret marker/reference.
- The merge logic should not unconditionally preserve
apiKey strings from existing models.json when the canonical auth source is a SecretRef in auth-profiles.json. The SecretRef should take precedence.
resolveApiKeyFromProfiles should be aware of exec-source refs — at minimum by returning a sentinel or marker instead of silently returning undefined.
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
Summary
models.jsonis an auto-generated runtime file rewritten on every gateway start. When a provider's API key is managed via an exec-source SecretRef inauth-profiles.json(e.g. macOS Keychain), the plaintext key can end up persisted inmodels.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
auth-profiles.json:~/.openclaw/agents/main/agent/models.json:"apiKey": "sk-proj-..."(plaintext on disk)Root cause
Two compounding issues in
auth-profilesandmodels-config:1.
resolveApiKeyFromProfilessilently drops exec-source SecretRefsThis synchronous function is used during
models.jsongeneration. Forapi_key-type profiles:Only 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
ensureOpenClawModelsJsoncreates permanent plaintext key persistenceOnce 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
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.models.json, the current code cannot introduce one (since exec refs are dropped). This is why Google is clean and OpenAI is not.Impact
models.json(mode 0600, but still plaintext at rest)models.jsonkeeps the stale old key via mergeSuggested 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.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
apiKeyfield from the affected provider entry inmodels.json. The async runtime path (resolveApiKeyForProvider→resolveApiKeyForProfile→resolveProfileSecretString) properly resolves exec SecretRefs at request time.Environment