fix(llm): stop BYOR/custom key leaking into managed-proxy profiles on the read path - #427
juanmichelini wants to merge 4 commits into
Conversation
… the read path Enterprise #421: after a user activates a broken custom ("dummy") model and switches back to the managed default, the default model stops working with a LiteLLM 401 ("LiteLLM Virtual Key expected"). Root cause on the read path: the member's effective key -- which may be a custom (BYOR) credential -- was attached to *any* profile, including managed ones routed at the LiteLLM proxy, which rejects third-party keys. Harden the read path so a non-managed key can never reach a managed profile: - resolve_profile_llm: add fallback_is_managed_key (default True to preserve behaviour). The managed-proxy branch (override + keyless fill) now runs only when the fallback is a managed key. A BYOR fallback no longer poisons a managed profile; the managed profile keeps its own (possibly stale) key and runtime rotation refresh heals it. - utils/llm.is_managed_llm_config: app-server-side classifier mirroring storage.managed_llm_key_config_from_model so the three resolve_profile_llm call sites (users_v1, app_conversation_router, live_status service) classify managed configs identically without importing storage. - saas_settings_store.load: compute effective_key_is_managed from has_custom_llm_api_key and gate the composed-path effective-key lift on it (also via _resolve_active_agent_profile). A managed active model is no longer handed a BYOR key sitting in the shared _llm_api_key slot. Complementary to #425, which fixes the write path (force-rotating a fresh managed key on profile switch-back so the dummy does not persist in the slot). This change contains the blast radius if a BYOR key ever reaches the shared slot (rotation failure, legacy store() paths, or a BYOR active default poisoning the exposed profile set). Tests: - test_resolve_profile_llm: 3 regression tests (BYOR fallback no longer overrides/fills a managed profile; managed fallback still overrides). - test_llm_utils: TestIsManagedLlmConfig covers is_managed_llm_config. - test_saas_settings_store: load() regressions -- a member's dummy BYOR key is not attached to a managed active model; a BYOR active model still receives its own key. resolve_profile_llm 10p, llm_utils 29p, saas_settings_store 49p, agent_profiles 48p, org_profiles + resolve_provider 51p -- all green. No new ruff violations (7 pre-existing I001 on main unchanged). Co-authored-by: openhands <openhands@all-hands.dev>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f1a8cce to
9792d42
Compare
Enterprise #421: the previous read-path guard gated the effective-key lift on the COMPOSED model, but materialize-Default + agent-profile resolution swap the model AFTER the lift. A BYOR key lifted onto the composed (BYOR) model stayed attached when the model was swapped to managed, so the managed Default still launched with the dummy BYOR key -> LiteLLM 401 ("LiteLLM Virtual Key expected"). Replace the composed-model gate with two cooperating pieces: 1. storage/saas_settings_store.load: keep the composed-model lift (it populates the persisted/seed view so settings writes/seed don't drop a user's key), gated on composed managed-ness == key managed-ness. Add a launch-key guard that runs ONLY in the launch (resolved) view, AFTER the effective model is finalized: if the FINAL model's managed-ness != the key's managed-ness, strip api_key to None. A managed model never launc key's managed-ness, strip api_key to None. A man Persisted/round-trip loads are untouched, so the settings UI and seed keep the user's key. 2. live_status_app_conversation_service._maybe_refresh_managed_llm_key: when a managed model has no usable key (None/masked) -- exactly what the guard produces after switching back from a broken custom model -- mint a fresh managed key via rotate_managed_llm_key(force=True), attach it, and invalidate the user-info cache. Also fix None api_key being coerced to the truthy string 'None' (which skipped the mint branch entirely). 3. saas_settings_store.rotate_managed_llm_key: add force param to relax the has_custom_llm_api_key bail so the mint proceeds even when the member is still in a BYOR state, and clears it. A truly BYOK org (org._llm_api_key set) is still rejected. Tests: - test_saas_settings_store: repro (dummy BYOR key no longer on managed Default -> api_key=None); DB-backed rotate(force=True) mints + clears has_custom, no-force still returns BYOK. - test_live_status_app_conversation_service: 3 mint tests (keyless, masked, mint-not-applied) for _maybe_refresh_managed_llm_key. saas_settings_store 51p, resolve_profile_llm 10p, llm_utils 29p, managed_key_rotation + api_keys 126p, maybe_refresh/configure 34p -- green. 1 pre-existing env-dependent failure (test_configure_llm_and_mcp_openhands _model_no_base_urls, LITE_LLM_API_URL=eval proxy) fails identically on main. Co-authored-by: openhands <openhands@all-hands.dev>
9792d42 to
92881a8
Compare
The previous guard only blocked the leak on the resolve-requested launch view
(conversation start at live_status_app_conversation_service.py:484). The actual
key supplied to the sandbox comes from _seed_llm_profiles_to_sandbox, which
fetches settings via a PLAIN get_user_info() (no resolve_agent_profile) — so
the launch-key guard in load() is skipped on that path:
- the composed-key lift attaches the member's BYOR/dummy key to
agent_settings.llm;
- materialize_default_llm_payload swaps the model to the managed "Default"
(model+base_url only), leaving the BYOR key attached;
- _maybe_refresh_managed_llm_key saw has_usable_key=True (dummy key is
non-empty) and skipped the mint branch;
- get_current_managed_llm_key() returns None in that same state (it bails
when has_custom_llm_api_key=True), and the None/mismatch early-returns
handed the BYOR key straight through;
- the dummy-keyed managed model hit the LiteLLM proxy -> 401
"LiteLLM Virtual Key expected. Received=dumm****odel".
Fix: make _maybe_refresh_managed_llm_key the single chokepoint. For a managed
model, mint a fresh managed key (rotate_managed_llm_key(force=True), which also
clears the BYOR state) whenever the carried key is NOT the org's current
managed virtual key — no usable key, no current managed key, or a mismatch
(wrong-type/stale). Only when the carried key equals the current managed key
does it verify-and-rotate-when-stale as before. This catches the leak
regardless of which load view (plain or resolved) produced the LLM.
Tests:
- test_maybe_refresh_managed_llm_key_mints_on_key_mismatch (renamed from
skips_key_mismatch): dummy BYOR key on a managed model -> mint.
- test_maybe_refresh_managed_llm_key_mints_when_no_current_managed_key: the
exact leak state (managed model + BYOR key + get_current_managed_llm_key
returns None) -> mint.
- mints_when_keyless/masked/mint_not_applied: updated for the new
get_current_managed_llm_key call ordering.
Co-authored-by: openhands <openhands@all-hands.dev>
The deployed 551f79b still leaked: preview logs showed the mint firing (mint_managed_key, reason=no_current_managed_key) but rotate_managed_llm_key returning status=byok, has_new_key=false -> mint_not_applied, so a masked key flowed to the proxy (401 "Received=****"). Root cause is one layer above the mint. _get_effective_llm_api_key returned the member's custom (dummy) key whenever has_custom_llm_api_key=True, regardless of the active model. has_custom is sticky: activating a broken BYOR model sets it, and switching the active model back to managed does NOT clear it. So a managed active model (openhands/deepseek-v4-flash) still got the member's stale dummy key as the "effective" key, which the composed lift attached to the managed model -> leak. And this org has an org-level managed key, so the per-member mint correctly bails BYOK (orgs with org-level keys don't use member managed keys) -> no fresh key -> masked -> 401. Fix: make key resolution model-aware. load() computes active_is_managed from the composed (active settings) model and passes it to _get_effective_llm_api_key. A managed active model now takes the org's managed key (or the member's own managed key when has_custom is False), NEVER the stale custom key. A BYOR/custom active model still uses the member's custom key (unchanged). effective_key_is_managed tracks active_is_managed so the composed lift only stamps a managed key onto a managed model. This resolves org-key orgs (the org sk- key is used, mint stays a no-op BYOK) and leaves the member-managed path (no org key) to the launch-time mint. Tests: - test_managed_active_model_uses_org_key_not_stale_custom_key: the leak repro (managed model + sticky has_custom + dummy key) -> org key, not dummy. - test_managed_active_model_returns_none_when_no_managed_key: no org key + BYOR state -> None (mint handles member-managed orgs). - test_byor_active_model_uses_member_custom_key: BYOR model keeps custom key. - existing _get_effective_llm_api_key tests updated for the new kwarg; test_load_does_not_attach_custom_byor_key_to_managed_active_model still passes (no org key in that fixture -> None). Co-authored-by: openhands <openhands@all-hands.dev>
Replicated VM verification — PASS for the intended (switch-back) scenario ✅Tested on a Replicated embedded-cluster VM (SaaS mode). Control = the baseline Scenario tested (this PR's target): a member left with a custom/BYOR key in Deterministic (store) check — the read-path guard works:
Live UI end-to-end — the launch-time mint reaches the runtime:
Scope note (vs #437): this PR fixes the member-level BYOR-on-managed case This comment was created by an AI agent (OpenHands) on behalf of the maintainer. |
Problem
SaaS "stale default model" bug (enterprise #421), reported from
saas-deployprod:Root cause (read path)
SaasSettingsStore._get_effective_llm_api_keyreturns the member's effective key, which may be a custom (BYOR) credential (whenhas_custom_llm_api_keyis true). Inload(), that key was lifted intoagent_settings['llm']['api_key']before the effective model was finalized. The materialize-Default step (and agent-profile resolution) can then swap the model to a managedopenhands/*one while the BYOR key is already attached — so the managed Default model launches carryingdummymodel, and the managed LiteLLM proxy rejects it with the 401 above.Why the previous version of this PR didn't work
The first attempt gated the effective-key lift on the composed model's managed-ness. But materialize-Default + agent-profile resolution swap the model after the lift, so a BYOR key lifted onto the composed (BYOR) model stayed attached when the model was swapped to managed. The gate ran too early.
Fix (repurposed, narrower read-path guard)
Two cooperating pieces, plus a relaxation to enable the mint:
saas_settings_store.load()— launch-key guard (defense-in-depth). Keep the composed-model lift (it populates the persisted/seed view so settings writes and the legacy-Default seed don't drop a user's key), gated oncomposed managed-ness == key managed-ness. Add a launch-key guard that runs only in the launch (resolved) view, after the effective model is finalized: if the final model's managed-ness != the key's managed-ness, stripapi_keytoNone. A managed model never launches with a BYOR key; a BYOR model keeps its own key. Persisted/round-trip loads are untouched, so the settings UI and seed keep the user's key.live_status_app_conversation_service._maybe_refresh_managed_llm_key— launch-time mint. When a managed model has no usable key (None/masked) — exactly what the guard produces after switching back from a broken custom model — mint a fresh managed key viarotate_managed_llm_key(force=True), attach it, and invalidate the user-info cache. Also fixesNoneapi_key being coerced to the truthy string'None', which previously skipped the mint branch entirely.saas_settings_store.rotate_managed_llm_key—forceparam. Relax thehas_custom_llm_api_keybail so the mint proceeds even when the member is still in a BYOR state, and clears that state. A truly BYOK org (org._llm_api_keyset) is still rejected — those orgs don't use managed keys.The earlier
resolve_profile_llm/is_managed_llm_configlayer from this PR's first revision is retained as additional defense-in-depth at the profile-resolution layer; the load() guard above is the primary fix because it operates on the finalized model.Complementary to #425, which fixes the write path. Together: #425 stops the dummy from being written into the shared slot, and this PR ensures that even if a BYOR key does reach the slot, it can never be handed to a managed profile at launch, and a managed model left keyless by the guard is self-healed by the launch-time mint.
Testing
tests/unit/test_saas_settings_store.py— repro: the dummy BYOR key is no longer attached to the managed Default model (api_key=None); DB-backedrotate_managed_llm_key(force=True)mints a fresh key and clearshas_custom_llm_api_key, while no-force still returnsBYOK. Suite: 51 passed.tests/unit/app_server/test_live_status_app_conversation_service.py— 3 new mint tests for_maybe_refresh_managed_llm_key: keyless managed model mints, masked key mints, mint-not-applied returns keyless (best-effort, never raises). Refresh/configure subset: 34 passed.tests/unit/app_server/test_resolve_profile_llm.py(10p),tests/unit/utils/test_llm_utils.py(29p),test_managed_key_rotation+test_api_keys(126p) — green.test_configure_llm_and_mcp_openhands_model_no_base_urls,LITE_LLM_API_URLresolves to the eval proxy in this env) fails identically onmain— unrelated to this change.ruff checkon changed files: 0 violations.This PR was created by an AI agent (OpenHands) on behalf of the repo maintainer.
Enterprise server image for this PR: