Design comparison · smolpaws

Secrets: keyring references vs. encrypted-at-rest

The Python agent-sdk keeps secret values on disk, encrypted with a Fernet cipher you also have to store. The fresh TypeScript transpilation keeps only a reference and resolves the real value from the OS keyring at use time. This page shows both, the exact delta, and what it means for the keyring feature request (#3988).

1The big picture

Both designs solve "how does an agent hold an LLM API key without leaking it." They differ on the one question that matters: does the secret value ever live on disk?

PYTHON agent-sdk — value on disk LLM.api_key SecretStr (memory) Cipher.encrypt() Fernet (AES) settings file on disk ciphertext + you store the key TS transpilation — only a reference on disk SecretRef {service, account} settings file on disk no value — just the ref OS keyring value resolved at use time
Same goal, one structural difference: the Python path writes an (encrypted) value to disk; the TS path writes only a pointer.

2Before — encrypted-at-rest (Python)

The SDK marks secret-bearing fields, then serializes them through a Fernet cipher. Three modes: plaintext, encrypted, or redact.

# llm.py:176 — the fields that carry secrets
LLM_SECRET_FIELDS = ("api_key", "aws_secret_access_key", ...)

# pydantic_secrets.py:48 — how each is written out
def serialize_secret(v: SecretStr | None, info):
    # cipher present -> "encrypted"; else plaintext or redact

# cipher.py:22 — the machinery
class Cipher:
    def encrypt(self, secret) -> str    # Fernet token, prefix "gAAAAA"
    def decrypt(self, secret) -> SecretStr

cipher.py:22 Cipher · pydantic_secrets.py:48 serialize_secret · llm.py:176 LLM_SECRET_FIELDS

The cost: encrypted-at-rest still puts the key material on disk — and the Fernet key that unlocks it has to live somewhere too. You've moved the problem, not removed it. And it's real surface area: a cipher class, a secrets serializer, a three-way expose-mode resolver, and every call site that has to know which mode it's in.

3After — keyring references (TS)

The transpilation persists a SecretRef (service + account) and nothing else. A SecretStore resolves the value on demand. The whole surface is one small file.

// secrets/index.ts:6 — one namespace
export const OPENHANDS_KEYRING_SERVICE = 'openhands';

// :8 — what lands on disk: a pointer, not a value
export const secretRefSchema = z.object({
  service: z.string().default(OPENHANDS_KEYRING_SERVICE),
  account: z.string(),        // e.g. "llm-provider:openai"
}).strict();

// :17 — pluggable backend
export interface SecretStore {
  get(ref), set(ref, value), has(ref), delete(ref)
}

// :85 — macOS backend, no deps, just the `security` CLI
class MacOSKeychainSecretStore implements SecretStore { ... }

secrets/index.ts:17 SecretStore · :85 MacOSKeychainSecretStore · :40 resolveLlmApiKeyRef

The gain: the settings file can be read by anyone and leaks nothing — there's no value in it, encrypted or otherwise. Provider-scoped by default (llm-provider:openai), with a per-profile override (llm-profile:<id>:api-key) for multi-proxy cases. Backends are swappable behind one interface, so headless/CI can use an in-memory or env store without touching call sites.

4The core difference

Python — value at rest on disk

settings.json
  api_key: "gAAAAA…"  # ciphertext
+ Fernet key stored somewhere
+ Cipher / serializer / expose-mode

TS — value in keyring off disk

settings.json
  apiKeyRef: {service, account}  # pointer
→ OS keyring holds the value
  (one SecretStore interface)
DimensionPython (encrypted-at-rest)TS (keyring ref)
Value on diskyes (encrypted)no
Key to unlock itFernet key, also storedguarded by OS login/keyring
Settings-file leak = secret leak?yes if cipher key also leaksno — ref is useless alone
Code surfacecipher + serializer + expose-mode + call-site awarenessone interface + backends
Rotationre-encrypt & rewrite filesupdate keyring, refs unchanged

5Where keyring actually works

OSBackendHowStatus
macOSKeychainsecurity CLIworks, no daemon
WindowsCredential Managerwincred / nativeworks (desktop)
Linux desktopSecret Service (libsecret)GNOME Keyring / KWallet over D-Busneeds D-Bus + unlocked keyring
Linux headlessno Secret Service by defaultkernel keyring (keyctl) or fallback
Docker containerno Keychain, no D-Busnot available OOTB

The TS SDK ships the macOS backend today; Windows and Linux-desktop are the same pattern behind the same SecretStore interface.

6Docker & whether Python can drop the old code

This is the decision Engel flagged. Keyring doesn't work in a stock container — but the answer isn't "keep the encrypted-file store."

Host / desktop macOS · Windows · Linux-with-session → OS keyring is the store → drop plaintext + Fernet Container / headless no keyring available → inject at runtime (env / mounted secret) → never persist to disk → nothing to encrypt
Two environments, two right answers — neither of which needs encrypted-at-rest.
So: the Fernet encrypted-at-rest path is only "needed" for the worst case — persisting a secret to disk somewhere that has neither a keyring nor runtime injection. That case should be discouraged, not designed for. Recommendation in #3988: land SecretStore with (1) OS-keyring backends and (2) an env/injected backend for containers, then remove the plaintext + Fernet code. Keep the old path only if a real deployment proves it needs on-disk persistence with no keyring and no injection.

7Verdict

The TS transpilation is doing it right: store a reference, not a value. It's less code, it fails safe (a leaked settings file is inert), and it matches how secrets are already handled on this machine (Keychain under service openhands). The Python SDK should adopt the same SecretStore shape; the encrypted-at-rest cipher becomes removable everywhere except a discouraged edge case that likely doesn't exist in practice.

Grounded to: TS src/secrets/index.ts (170-test transpilation, @smolpaws/openhands-agent 0.1.0) · Python openhands-sdk/openhands/sdk/utils/{cipher,pydantic_secrets}.py and llm/llm.py. Filed as OpenHands/software-agent-sdk#3988.