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?
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
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
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)
| Dimension | Python (encrypted-at-rest) | TS (keyring ref) |
|---|---|---|
| Value on disk | yes (encrypted) | no |
| Key to unlock it | Fernet key, also stored | guarded by OS login/keyring |
| Settings-file leak = secret leak? | yes if cipher key also leaks | no — ref is useless alone |
| Code surface | cipher + serializer + expose-mode + call-site awareness | one interface + backends |
| Rotation | re-encrypt & rewrite files | update keyring, refs unchanged |
5Where keyring actually works
| OS | Backend | How | Status |
|---|---|---|---|
| macOS | Keychain | security CLI | works, no daemon |
| Windows | Credential Manager | wincred / native | works (desktop) |
| Linux desktop | Secret Service (libsecret) | GNOME Keyring / KWallet over D-Bus | needs D-Bus + unlocked keyring |
| Linux headless | — | no Secret Service by default | kernel keyring (keyctl) or fallback |
| Docker container | — | no Keychain, no D-Bus | not 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."
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.
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.