OpenHands / OpenHands/software-agent-sdk
Encrypted LLM profiles are decrypted only on the conversation path — FallbackStrategy (and sub-agents) load them without the cipher
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 1.1k
- Forks
- 539
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 137
Description
Is there an existing issue for the same bug?
- I have searched existing issues and this is not a duplicate.
Bug Description
LLMProfileStore.load() takes an optional cipher, and only the conversation-launch path passes it. Every other caller loads with cipher=None, so on a server that has an OH_SECRET_KEY (Agent Canvas always generates and passes one) the Fernet ciphertext stored at rest is handed to the provider as the API key.
Two call sites are affected:
| call site | status |
|---|---|
openhands-sdk/openhands/sdk/subagent/registry.py:228 — agent_definition_to_factory |
reported in #4270; fix PRs #4413 and #4183 open, neither merged |
openhands-sdk/openhands/sdk/llm/fallback_strategy.py:142 — FallbackStrategy._iter_fallbacks |
not reported, and not covered by either PR |
For contrast, the path that gets it right is openhands-sdk/openhands/sdk/profiles/resolver.py:336 (llm_store.load(profile.llm_profile_ref, cipher=cipher)), wired from conversation_service.py:353-359.
openhands-tools/openhands/tools/preset/default.py:140 uses the same factory, but every builtin subagent ships model: inherit, so it never reaches the store.
This issue tracks the FallbackStrategy call site and the structural fix; the sub-agent call site is left to #4270 and its PRs.
Expected Behavior
A profile persisted with the server cipher resolves to its decrypted API key through every resolution path, exactly as it does for the conversation's primary agent.
Actual Behavior
The fallback LLM is constructed with the Fernet ciphertext as its API key. Reproduced on main @ d98fd9500 with uv run python /tmp/repro.py (source under Minimal Code Sample):
$ uv run python /tmp/repro.py
fallback model : anthropic/claude-sonnet-4-5
fallback api_key: gAAAAABqhuqAuR ...
is ciphertext? : True
The provider then rejects it, e.g. the report in #4270: litellm.AuthenticationError: LiteLLM Virtual Key expected. Received=gAAA****L7Q=, expected to start with 'sk-'.
For the fallback path this is worse than a plain failure: try_fallback swallows the auth error per fallback (fallback_strategy.py:145-149) and re-raises the primary error, so the operator sees the original failure and no indication that every fallback was unusable.
Steps to Reproduce
- Save the repro under Minimal Code Sample to
/tmp/repro.py. - From a checkout of this repo, run
uv run python /tmp/repro.py. - Observe the fallback LLM's
api_keyis thegAAAAA…Fernet token rather thansk-REAL.
Equivalent end-to-end path: start the agent-server with OH_SECRET_KEY set, save an LLM profile through POST /api/profiles/{name} (which encrypts at rest, profiles_router.py:201-206), then reference that profile from another profile's fallback_strategy.fallback_llms and force a primary-model failure.
Acceptance Criteria
-
FallbackStrategyresolves fallback profiles with the server cipher, so an encrypted profile yields its decrypted API key. - Omitting the cipher is made structurally impossible rather than fixed per call site:
cipherbecomes a required keyword-only argument onLLMProfileStore.load(), or is bound at construction (LLMProfileStore(base_dir, cipher=...)). - The
LLMProfileLoaderprotocol (llm_profile_store.py:42-50) stays satisfiable by alternate/cloud backends after that change. - A regression test asserts a cipher-saved profile resolves to the decrypted key through the fallback path, not just the conversation-launch path.
- An audit confirms no remaining
LLMProfileStore.load()call site drops the cipher (the sub-agent site is covered by #4270 / #4413 / #4183).
Installation Method
Local checkout (uv run)
SDK Version
main @ d98fd9500 (openhands-sdk 1.42.1)
Version Confirmation
- I have confirmed this bug exists on the LATEST version of OpenHands SDK
Python Version
3.13.11
Model Name (if applicable)
Reproduces with any provider; the report in #4270 hit it through the OpenHands LiteLLM proxy.
Operating System
macOS 26.5 (arm64); not platform-specific.
Logs and Error Messages
litellm.AuthenticationError: LiteLLM Virtual Key expected. Received=gAAA****L7Q=, expected to start with 'sk-'
On the fallback path this line never surfaces — it is caught at fallback_strategy.py:145-149 and the primary error is re-raised instead.
Minimal Code Sample
import tempfile
from pathlib import Path
from pydantic import SecretStr
from openhands.sdk.llm import LLM
from openhands.sdk.llm.fallback_strategy import FallbackStrategy
from openhands.sdk.llm.llm_profile_store import LLMProfileStore
from openhands.sdk.utils.cipher import Cipher
cipher = Cipher("server-oh-secret-key")
d = Path(tempfile.mkdtemp())
store = LLMProfileStore(d)
# exactly how profiles_router.py:201-206 persists a profile when OH_SECRET_KEY is set
store.save("backup", LLM(model="anthropic/claude-sonnet-4-5",
api_key=SecretStr("sk-REAL"), usage_id="backup"),
include_secrets=True, cipher=cipher)
primary = LLM(model="anthropic/claude-opus-4-5", api_key=SecretStr("sk-PRIMARY"),
usage_id="primary",
fallback_strategy=FallbackStrategy(fallback_llms=["backup"],
profile_store_dir=str(d)))
fb = next(primary.fallback_strategy._iter_fallbacks())
print("fallback model :", fb.model)
print("fallback api_key:", fb.api_key.get_secret_value()[:14], "...")
print("is ciphertext? :", fb.api_key.get_secret_value().startswith("gAAAAA"))
Screenshots and Additional Context
Why this stays invisible for some users. validate_secret only attempts decryption when the value starts with gAAAAA (utils/pydantic_secrets.py:110-118). A plaintext profile therefore behaves identically on both paths:
legacy/plaintext profile main-agent=sk-REAL sub-agent=sk-REAL sub OK=True
GUI-saved w/ cipher main-agent=sk-REAL sub-agent=gAAAAABqhu sub OK=False
Encryption is applied only at save time (#3161, 2026-05-08), store.save() is the only writer, and no migration re-encrypts on load. So profiles created before that change — or by any server started without OH_SECRET_KEY / a session key — stay plaintext on disk and keep working indefinitely, for sub-agents and fallbacks alike. Those users have working setups that break the first time they re-save the profile in the UI, and a fresh install cannot reproduce their recipe. That is the source of the conflicting reports on #4270.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the Minimal Code Sample using uv run, then inspect FallbackStrategy._iter_fallbacks in openhands-sdk/openhands/sdk/llm/fallback_strategy.py and LLMProfileStore.load() in llm_profile_store.py. Trace the working resolver path in profiles/resolver.py and the LLMProfileLoader protocol at llm_profile_store.py:42-50. Done means encrypted fallback profiles yield decrypted keys, regression coverage exists, alternate backends remain valid, and no load call site drops the cipher.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100