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.