Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions openhands-agent-server/openhands/agent_server/profiles_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
62 changes: 61 additions & 1 deletion tests/agent_server/test_profiles_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
):
Expand Down
60 changes: 60 additions & 0 deletions tests/cross/test_remote_conversation_live_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading