From b3001edd5dc7441d22286f2dc8161a31ad1f8be0 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Thu, 10 Sep 2026 07:33:20 +0200 Subject: [PATCH] fix(agent-server): resolve providers for plaintext profile reads Return current linked credentials to runtime clients without changing stored profiles or default settings. Keep editor reads unresolved. Cover encrypted storage, rotation, and real HTTP profile selection with RemoteWorkspace. Co-authored-by: openhands --- .../openhands/agent_server/profiles_router.py | 14 +++-- tests/agent_server/test_profiles_router.py | 62 ++++++++++++++++++- .../test_remote_conversation_live_server.py | 60 ++++++++++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/profiles_router.py b/openhands-agent-server/openhands/agent_server/profiles_router.py index 578fe0a044..49ccdafa37 100644 --- a/openhands-agent-server/openhands/agent_server/profiles_router.py +++ b/openhands-agent-server/openhands/agent_server/profiles_router.py @@ -69,7 +69,7 @@ class ProfileListResponse(BaseModel): class ProfileDetailResponse(BaseModel): - """``config.api_key`` is always nulled; use ``api_key_set`` instead.""" + """Secrets are nulled unless explicitly requested via X-Expose-Secrets.""" name: str config: dict[str, Any] @@ -171,7 +171,8 @@ async def get_profile(request: Request, name: ProfileName) -> ProfileDetailRespo Use the ``X-Expose-Secrets`` header to control secret exposure: - ``encrypted``: Returns cipher-encrypted values (safe for frontend clients) - - ``plaintext``: Returns raw secret values (backend clients only!) + - ``plaintext``: Resolves linked providers and returns raw secret values + for backend clients constructing a runnable LLM configuration - (absent): Returns nulled ``api_key`` with ``api_key_set`` indicator """ expose_mode = parse_expose_secrets_header(request) @@ -180,10 +181,11 @@ async def get_profile(request: Request, name: ProfileName) -> ProfileDetailRespo store = get_llm_profile_store() try: with store_errors(): - # Display the profile exactly as stored: don't inject the linked - # provider's credentials, and don't fail a read when the reference - # dangles. Effective key presence is reported via ``api_key_set``. - llm = store.load(name, cipher=cipher, resolve_provider=False) + # Runtime reads need current provider credentials. Editor reads + # retain the stored reference, including dangling references. + llm = store.load( + name, cipher=cipher, resolve_provider=expose_mode == "plaintext" + ) except FileNotFoundError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/tests/agent_server/test_profiles_router.py b/tests/agent_server/test_profiles_router.py index 29ae961a89..91ca99ee9f 100644 --- a/tests/agent_server/test_profiles_router.py +++ b/tests/agent_server/test_profiles_router.py @@ -16,7 +16,10 @@ from openhands.sdk.llm import LLM from openhands.sdk.llm.auth.credentials import OAuthCredentials from openhands.sdk.llm.llm_profile_store import LLMProfileStore -from openhands.sdk.llm.provider_connection_store import ProviderConnectionStore +from openhands.sdk.llm.provider_connection_store import ( + ProviderConnection, + ProviderConnectionStore, +) from openhands.sdk.profiles import AgentProfileStore, OpenHandsAgentProfile @@ -244,8 +247,15 @@ def test_provider_connection_key_shared_by_linked_profiles(client): detail = client.get("/api/profiles/sonnet-4").json() assert detail["config"]["api_key"] is None + assert detail["config"]["base_url"] is None assert detail["api_key_set"] is True + runtime = client.get( + "/api/profiles/sonnet-4", headers={"X-Expose-Secrets": "plaintext"} + ).json()["config"] + assert runtime["api_key"] == "sk-ant-old" + assert runtime["base_url"] == "https://api.anthropic.com" + activated = client.post("/api/profiles/sonnet-4/activate") assert activated.status_code == 200 settings = client.get( @@ -272,6 +282,11 @@ def test_provider_connection_key_shared_by_linked_profiles(client): ).json() assert settings["agent_settings"]["llm"]["api_key"] == "sk-ant-old" + runtime = client.get( + "/api/profiles/sonnet-4", headers={"X-Expose-Secrets": "plaintext"} + ).json()["config"] + assert runtime["api_key"] == "sk-ant-new" + # Re-activating re-resolves the connection and applies the rotated key. activated = client.post("/api/profiles/sonnet-4/activate") assert activated.status_code == 200 @@ -1129,6 +1144,51 @@ def test_get_profile_with_plaintext_header_exposes_secrets( assert body["config"]["api_key"] == "sk-test-secret-key" +@pytest.mark.parametrize("expose_mode", [None, "encrypted", "plaintext"]) +def test_linked_profile_with_encrypted_provider_credentials( + client_with_cipher, store, temp_profiles_dir, cipher, expose_mode +): + """Only runtime reads resolve a provider's encrypted-at-rest credentials.""" + provider_store = ProviderConnectionStore( + base_dir=temp_profiles_dir.parent / "provider-connections" + ) + provider_store.create( + ProviderConnection( + id="automation-provider", + display_name="Automation provider", + api_key=SecretStr("sk-linked-secret"), + base_url="https://provider.example/v1", + created_at=1, + updated_at=1, + ), + cipher=cipher, + ) + store.save( + "linked-profile", + LLM(model="gpt-4o", provider_connection_id="automation-provider"), + cipher=cipher, + ) + + response = client_with_cipher.get( + "/api/profiles/linked-profile", + headers={"X-Expose-Secrets": expose_mode} if expose_mode else {}, + ) + + assert response.status_code == 200 + config = response.json()["config"] + assert config["provider_connection_id"] == "automation-provider" + if expose_mode == "plaintext": + assert config["api_key"] == "sk-linked-secret" + assert config["base_url"] == "https://provider.example/v1" + else: + assert config["api_key"] is None + assert config["base_url"] is None + # Reading runtime credentials must not copy them into the stored profile. + stored = store.load("linked-profile", cipher=cipher, resolve_provider=False) + assert stored.api_key is None + assert stored.base_url is None + + def test_get_profile_with_encrypted_header_encrypts_secrets( client_with_cipher, store, cipher ): diff --git a/tests/cross/test_remote_conversation_live_server.py b/tests/cross/test_remote_conversation_live_server.py index dc544100a8..3b412b0cdd 100644 --- a/tests/cross/test_remote_conversation_live_server.py +++ b/tests/cross/test_remote_conversation_live_server.py @@ -2362,6 +2362,66 @@ def test_workspace_default_llm_resolves_active_profile_despite_settings_drift( assert explicit_llm.usage_id == "profile:explicit-model" +def test_workspace_named_llm_resolves_current_provider_credentials( + tmp_path, monkeypatch +): + """Selecting a linked profile resolves credentials without activating it.""" + with live_server_env(tmp_path, monkeypatch) as env: + workspace = RemoteWorkspace( + host=env["host"], working_dir=str(env["workspace_path"]) + ) + with httpx.Client(base_url=env["host"], timeout=10.0) as client: + settings_before = client.get("/api/settings").json() + connection = client.post( + "/api/llm/provider-connections", + json={ + "display_name": "Automation provider", + "provider": "openai", + "api_key": "sk-provider-old", + "base_url": "https://provider.example/v1", + }, + ) + assert connection.status_code == 201 + connection_id = connection.json()["id"] + saved = client.post( + "/api/profiles/automation-model", + json={ + "llm": { + "model": "openai/gpt-4o-mini", + "provider_connection_id": connection_id, + } + }, + ) + assert saved.status_code == 201 + + detail = client.get("/api/profiles/automation-model").json() + assert detail["config"]["api_key"] is None + assert detail["config"]["base_url"] is None + assert detail["api_key_set"] is True + + selected = workspace.get_llm(profile_name="automation-model") + assert selected.model == "openai/gpt-4o-mini" + assert selected.base_url == "https://provider.example/v1" + assert selected.api_key is not None + _assert_secret(selected.api_key, "sk-provider-old") + + rotated = client.patch( + f"/api/llm/provider-connections/{connection_id}", + json={"api_key": "sk-provider-new"}, + ) + assert rotated.status_code == 200 + selected = workspace.get_llm(profile_name="automation-model") + assert selected.api_key is not None + _assert_secret(selected.api_key, "sk-provider-new") + + settings_after = client.get("/api/settings").json() + assert settings_after["active_profile"] == settings_before["active_profile"] + assert ( + settings_after["agent_settings"]["llm"] + == settings_before["agent_settings"]["llm"] + ) + + def test_settings_and_secrets_api_with_live_server(server_env): """End-to-end test for settings and secrets API endpoints.