Skip to content

normalizeProviders leaks plaintext API keys into models.json for exec-source SecretRefs #34335

@Clawbob51

Description

@Clawbob51

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

  1. 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" }
}
  1. Restart the gateway.
  2. 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

  1. 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.
  2. 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.
  3. 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 (resolveApiKeyForProviderresolveApiKeyForProfileresolveProfileSecretString) 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions