OpenHands / OpenHands/software-agent-sdk

Encrypted LLM profiles are decrypted only on the conversation path — FallbackStrategy (and sub-agents) load them without the cipher

Open
#4,558 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug llm priority:high ready-for-dev security-related
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:228agent_definition_to_factory reported in #4270; fix PRs #4413 and #4183 open, neither merged
openhands-sdk/openhands/sdk/llm/fallback_strategy.py:142FallbackStrategy._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
  1. Save the repro under Minimal Code Sample to /tmp/repro.py.
  2. From a checkout of this repo, run uv run python /tmp/repro.py.
  3. Observe the fallback LLM's api_key is the gAAAAA… Fernet token rather than sk-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
  • FallbackStrategy resolves 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: cipher becomes a required keyword-only argument on LLMProfileStore.load(), or is bound at construction (LLMProfileStore(base_dir, cipher=...)).
  • The LLMProfileLoader protocol (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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.