diff --git a/examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py b/examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py new file mode 100644 index 0000000000..1547c92a97 --- /dev/null +++ b/examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py @@ -0,0 +1,115 @@ +"""Example: Inherit SaaS credentials via OpenHandsCloudWorkspace. + +This example shows the simplified flow where your OpenHands Cloud account's +LLM configuration and secrets are inherited automatically — no need to +provide LLM_API_KEY separately. + +Compared to 07_convo_with_cloud_workspace.py (which requires a separate +LLM_API_KEY), this approach uses: + - workspace.get_llm() → fetches LLM config from your SaaS account + - workspace.get_secrets() → builds lazy LookupSecret references for your secrets + +Raw secret values never transit through the SDK client. The agent-server +inside the sandbox resolves them on demand. + +Usage: + uv run examples/02_remote_agent_server/10_cloud_workspace_share_credentials.py + +Requirements: + - OPENHANDS_CLOUD_API_KEY: API key for OpenHands Cloud (the only credential needed) + +Optional: + - OPENHANDS_CLOUD_API_URL: Override the Cloud API URL (default: https://app.all-hands.dev) + - LLM_MODEL: Override the model from your SaaS settings +""" + +import os +import time + +from openhands.sdk import ( + Conversation, + RemoteConversation, + get_logger, +) +from openhands.tools.preset.default import get_default_agent +from openhands.workspace import OpenHandsCloudWorkspace + + +logger = get_logger(__name__) + + +cloud_api_key = os.getenv("OPENHANDS_CLOUD_API_KEY") +if not cloud_api_key: + logger.error("OPENHANDS_CLOUD_API_KEY required") + exit(1) + +cloud_api_url = os.getenv("OPENHANDS_CLOUD_API_URL", "https://app.all-hands.dev") +logger.info(f"Using OpenHands Cloud API: {cloud_api_url}") + +with OpenHandsCloudWorkspace( + cloud_api_url=cloud_api_url, + cloud_api_key=cloud_api_key, +) as workspace: + # --- LLM from SaaS account settings --- + # get_llm() calls GET /users/me?expose_secrets=true + # (dual auth: Bearer + session key) and returns a + # fully configured LLM instance. + # Override any parameter: workspace.get_llm(model="gpt-4o") + llm = workspace.get_llm() + logger.info(f"LLM configured: model={llm.model}") + + # --- Secrets from SaaS account --- + # get_secrets() fetches secret *names* (not values) and builds LookupSecret + # references. Values are resolved lazily inside the sandbox. + secrets = workspace.get_secrets() + logger.info(f"Available secrets: {list(secrets.keys())}") + + # Build agent and conversation + agent = get_default_agent(llm=llm, cli_mode=True) + received_events: list = [] + last_event_time = {"ts": time.time()} + + def event_callback(event) -> None: + received_events.append(event) + last_event_time["ts"] = time.time() + + conversation = Conversation( + agent=agent, workspace=workspace, callbacks=[event_callback] + ) + assert isinstance(conversation, RemoteConversation) + + # Inject SaaS secrets into the conversation + if secrets: + conversation.update_secrets(secrets) + logger.info(f"Injected {len(secrets)} secrets into conversation") + + # Build a prompt that exercises the injected secrets by asking the agent to + # print the last 50% of each token — proves values resolved without leaking + # full secrets in logs. + secret_names = list(secrets.keys()) if secrets else [] + if secret_names: + names_str = ", ".join(f"${name}" for name in secret_names) + prompt = ( + f"For each of these environment variables: {names_str} — " + "print the variable name and the LAST 50% of its value " + "(i.e. the second half of the string). " + "Then write a short summary into SECRETS_CHECK.txt." + ) + else: + # No secret was configured on OpenHands Cloud + prompt = "Tell me, is there any secret configured for you?" + + try: + conversation.send_message(prompt) + conversation.run() + + while time.time() - last_event_time["ts"] < 2.0: + time.sleep(0.1) + + cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost + print(f"EXAMPLE_COST: {cost}") + finally: + conversation.close() + + logger.info("✅ Conversation completed successfully.") + logger.info(f"Total {len(received_events)} events received during conversation.") diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index d9bac2dc6e..d59f1e8362 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -1185,15 +1185,20 @@ def pause(self) -> None: ) def update_secrets(self, secrets: Mapping[str, SecretValue]) -> None: - # Convert SecretValue to strings for JSON serialization - # SecretValue can be str or callable, we need to handle both - serializable_secrets = {} + from openhands.sdk.secret.secrets import SecretSource + + serializable_secrets: dict[str, str | dict] = {} for key, value in secrets.items(): - if callable(value): - # If it's a callable, call it to get the actual secret + if isinstance(value, SecretSource): + # Pydantic model → dict with "kind" discriminator for server. + # expose_secrets=True prevents SecretStr fields (e.g. header + # values) from being redacted during serialization. + serializable_secrets[key] = value.model_dump( + mode="json", context={"expose_secrets": True} + ) + elif callable(value): serializable_secrets[key] = value() else: - # If it's already a string, use it directly serializable_secrets[key] = value payload = {"secrets": serializable_secrets} diff --git a/openhands-sdk/openhands/sdk/secret/secrets.py b/openhands-sdk/openhands/sdk/secret/secrets.py index fb1cdac138..5b4723b9ff 100644 --- a/openhands-sdk/openhands/sdk/secret/secrets.py +++ b/openhands-sdk/openhands/sdk/secret/secrets.py @@ -52,7 +52,7 @@ class LookupSecret(SecretSource): url: str headers: dict[str, str] = Field(default_factory=dict) - def get_value(self): + def get_value(self) -> str: response = httpx.get(self.url, headers=self.headers, timeout=30.0) response.raise_for_status() return response.text diff --git a/openhands-workspace/openhands/workspace/cloud/workspace.py b/openhands-workspace/openhands/workspace/cloud/workspace.py index a4a9a88e5c..4b56b32287 100644 --- a/openhands-workspace/openhands/workspace/cloud/workspace.py +++ b/openhands-workspace/openhands/workspace/cloud/workspace.py @@ -1,6 +1,8 @@ """OpenHands Cloud workspace implementation using Cloud API.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any from urllib.request import urlopen import httpx @@ -11,11 +13,26 @@ from openhands.sdk.workspace.remote.base import RemoteWorkspace +if TYPE_CHECKING: + from openhands.sdk.llm.llm import LLM + from openhands.sdk.secret import LookupSecret + + logger = get_logger(__name__) # Standard exposed URL names from OpenHands Cloud AGENT_SERVER = "AGENT_SERVER" +# Number of retry attempts for transient API failures +_MAX_RETRIES = 3 + + +def _is_retryable_error(error: BaseException) -> bool: + """Return True for transient errors that are worth retrying.""" + if isinstance(error, httpx.HTTPStatusError): + return error.response.status_code >= 500 + return isinstance(error, (httpx.ConnectError, httpx.TimeoutException)) + class OpenHandsCloudWorkspace(RemoteWorkspace): """Remote workspace using OpenHands Cloud API. @@ -347,7 +364,7 @@ def cleanup(self) -> None: logger.info(f"Deleting sandbox {self._sandbox_id}...") self._send_api_request( "DELETE", - f"{self.cloud_api_url}/api/v1/sandboxes", + f"{self.cloud_api_url}/api/v1/sandboxes/{self._sandbox_id}", params={"sandbox_id": self._sandbox_id}, timeout=30.0, ) @@ -364,10 +381,165 @@ def cleanup(self) -> None: except Exception: pass + # ----------------------------------------------------------------- + # Settings helpers + # ----------------------------------------------------------------- + + @property + def _settings_base_url(self) -> str: + """Base URL for sandbox-scoped settings endpoints.""" + return f"{self.cloud_api_url}/api/v1/sandboxes/{self._sandbox_id}/settings" + + @property + def _session_headers(self) -> dict[str, str]: + """Headers for settings requests (SESSION_API_KEY auth).""" + return {"X-Session-API-Key": self._session_api_key or ""} + + @tenacity.retry( + stop=tenacity.stop_after_attempt(_MAX_RETRIES), + wait=tenacity.wait_exponential(multiplier=1, min=1, max=5), + retry=tenacity.retry_if_exception(_is_retryable_error), + reraise=True, + ) + def get_llm(self, **llm_kwargs: Any) -> LLM: + """Fetch LLM settings from the user's SaaS account and return an LLM. + + Calls ``GET /api/v1/users/me?expose_secrets=true`` to retrieve the + user's LLM configuration (model, api_key, base_url) and returns a + fully usable ``LLM`` instance. Retries up to 3 times on transient + errors (network issues, server 5xx). + + Args: + **llm_kwargs: Additional keyword arguments passed to the LLM + constructor, allowing overrides of any LLM parameter + (e.g. ``model``, ``temperature``). + + Returns: + An LLM instance configured with the user's SaaS credentials. + + Raises: + httpx.HTTPStatusError: If the API request fails. + RuntimeError: If the sandbox is not running. + + Example: + >>> with OpenHandsCloudWorkspace(...) as workspace: + ... llm = workspace.get_llm() + ... agent = Agent(llm=llm, tools=get_default_tools()) + """ + from openhands.sdk.llm.llm import LLM + + if not self._sandbox_id: + raise RuntimeError("Sandbox is not running") + + resp = self._send_api_request( + "GET", + f"{self.cloud_api_url}/api/v1/users/me", + params={"expose_secrets": "true"}, + headers={"X-Session-API-Key": self._session_api_key or ""}, + ) + data = resp.json() + + kwargs: dict[str, Any] = {} + if data.get("llm_model"): + kwargs["model"] = data["llm_model"] + if data.get("llm_api_key"): + kwargs["api_key"] = data["llm_api_key"] + if data.get("llm_base_url"): + kwargs["base_url"] = data["llm_base_url"] + + # User-provided kwargs take precedence + kwargs.update(llm_kwargs) + + return LLM(**kwargs) + + def get_secrets(self, names: list[str] | None = None) -> dict[str, LookupSecret]: + """Build ``LookupSecret`` references for the user's SaaS secrets. + + Fetches the list of available secret **names** from the SaaS (no raw + values) and returns a dict of ``LookupSecret`` objects whose URLs + point to per-secret endpoints. The agent-server resolves each + ``LookupSecret`` lazily, so raw values **never** transit through + the SDK client. + + The returned dict is compatible with ``conversation.update_secrets()``. + + Args: + names: Optional list of secret names to include. If ``None``, + all available secrets are returned. + + Returns: + A dictionary mapping secret names to ``LookupSecret`` instances. + + Raises: + httpx.HTTPStatusError: If the API request fails. + RuntimeError: If the sandbox is not running. + + Example: + >>> with OpenHandsCloudWorkspace(...) as workspace: + ... secrets = workspace.get_secrets() + ... conversation.update_secrets(secrets) + ... + ... # Or a subset + ... gh = workspace.get_secrets(names=["GITHUB_TOKEN"]) + ... conversation.update_secrets(gh) + """ + from openhands.sdk.secret import LookupSecret + + if not self._sandbox_id: + raise RuntimeError("Sandbox is not running") + + resp = self._send_settings_request("GET", f"{self._settings_base_url}/secrets") + data = resp.json() + + result: dict[str, LookupSecret] = {} + for item in data.get("secrets", []): + name = item["name"] + if names is not None and name not in names: + continue + result[name] = LookupSecret( + url=f"{self._settings_base_url}/secrets/{name}", + headers={"X-Session-API-Key": self._session_api_key or ""}, + description=item.get("description"), + ) + + return result + + @tenacity.retry( + stop=tenacity.stop_after_attempt(_MAX_RETRIES), + wait=tenacity.wait_exponential(multiplier=1, min=1, max=5), + retry=tenacity.retry_if_exception(_is_retryable_error), + reraise=True, + ) + def _send_settings_request( + self, method: str, url: str, **kwargs: Any + ) -> httpx.Response: + """Send a request to sandbox settings endpoints (SESSION_API_KEY auth). + + Retries up to 3 times on transient errors (network issues, server 5xx). + """ + headers = kwargs.pop("headers", {}) + headers.update(self._session_headers) + + timeout = kwargs.pop("timeout", self.api_timeout) + with httpx.Client(timeout=timeout) as api_client: + response = api_client.request(method, url, headers=headers, **kwargs) + + try: + response.raise_for_status() + except httpx.HTTPStatusError: + try: + error_detail = response.json() + logger.error(f"Settings request failed: {error_detail}") + except Exception: + logger.error(f"Settings request failed: {response.text}") + raise + + return response + def __del__(self) -> None: self.cleanup() - def __enter__(self) -> "OpenHandsCloudWorkspace": + def __enter__(self) -> OpenHandsCloudWorkspace: return self def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: diff --git a/tests/workspace/test_cloud_workspace_sdk_settings.py b/tests/workspace/test_cloud_workspace_sdk_settings.py new file mode 100644 index 0000000000..904b50ad87 --- /dev/null +++ b/tests/workspace/test_cloud_workspace_sdk_settings.py @@ -0,0 +1,262 @@ +"""Tests for OpenHandsCloudWorkspace.get_llm() and get_secrets() methods. + +get_llm() returns a real LLM with the raw api_key from SaaS. +get_secrets() returns LookupSecret references — raw values only flow +SaaS→sandbox, never to the SDK client. +""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from pydantic import SecretStr + +from openhands.sdk.secret import LookupSecret +from openhands.workspace.cloud.workspace import OpenHandsCloudWorkspace + + +SANDBOX_ID = "sb-test-123" +SESSION_KEY = "session-key-abc" +CLOUD_URL = "https://app.all-hands.dev" + + +@pytest.fixture +def mock_workspace(): + """Create a workspace instance with mocked sandbox lifecycle.""" + with patch.object( + OpenHandsCloudWorkspace, "model_post_init", lambda self, ctx: None + ): + workspace = OpenHandsCloudWorkspace( + cloud_api_url=CLOUD_URL, + cloud_api_key="test-api-key", + host="http://localhost:8000", + ) + # Simulate a running sandbox + workspace._sandbox_id = SANDBOX_ID + workspace._session_api_key = SESSION_KEY + return workspace + + +class TestGetLLM: + """Tests for OpenHandsCloudWorkspace.get_llm().""" + + def test_get_llm_returns_usable_llm(self, mock_workspace): + """get_llm fetches SaaS config and returns a usable LLM.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "llm_model": "anthropic/claude-sonnet-4-20250514", + "llm_api_key": "sk-test-key-123", + "llm_base_url": "https://litellm.example.com", + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_api_request", return_value=mock_response + ) as mock_req: + llm = mock_workspace.get_llm() + + mock_req.assert_called_once_with( + "GET", + f"{CLOUD_URL}/api/v1/users/me", + params={"expose_secrets": "true"}, + headers={"X-Session-API-Key": SESSION_KEY}, + ) + assert llm.model == "anthropic/claude-sonnet-4-20250514" + # api_key is a real SecretStr (LLM validator converts str → SecretStr) + assert isinstance(llm.api_key, SecretStr) + assert llm.api_key.get_secret_value() == "sk-test-key-123" + assert llm.base_url == "https://litellm.example.com" + + def test_get_llm_allows_overrides(self, mock_workspace): + """User-provided kwargs override SaaS settings.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "llm_model": "anthropic/claude-sonnet-4-20250514", + "llm_api_key": "sk-test-key", + "llm_base_url": None, + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_api_request", return_value=mock_response + ): + llm = mock_workspace.get_llm(model="gpt-4o", temperature=0.5) + + assert llm.model == "gpt-4o" + assert llm.temperature == 0.5 + assert isinstance(llm.api_key, SecretStr) + + def test_get_llm_no_api_key_still_works(self, mock_workspace): + """If no API key is configured, the LLM gets api_key=None.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "llm_model": "gpt-4o", + "llm_api_key": None, + "llm_base_url": None, + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_api_request", return_value=mock_response + ): + llm = mock_workspace.get_llm() + + assert llm.model == "gpt-4o" + assert llm.api_key is None + + def test_get_llm_raises_when_no_sandbox(self, mock_workspace): + """get_llm raises RuntimeError when sandbox is not running.""" + mock_workspace._sandbox_id = None + with pytest.raises(RuntimeError, match="Sandbox is not running"): + mock_workspace.get_llm() + + +class TestGetSecrets: + """Tests for OpenHandsCloudWorkspace.get_secrets().""" + + def test_get_all_secrets_returns_lookup_secrets(self, mock_workspace): + """get_secrets returns LookupSecret instances, not raw values.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "secrets": [ + {"name": "GITHUB_TOKEN", "description": "GitHub token"}, + {"name": "MY_API_KEY", "description": None}, + ] + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_settings_request", return_value=mock_response + ) as mock_req: + secrets = mock_workspace.get_secrets() + + mock_req.assert_called_once_with( + "GET", + f"{CLOUD_URL}/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets", + ) + + assert len(secrets) == 2 + assert "GITHUB_TOKEN" in secrets + assert "MY_API_KEY" in secrets + + gh_secret = secrets["GITHUB_TOKEN"] + assert isinstance(gh_secret, LookupSecret) + assert gh_secret.url == ( + f"{CLOUD_URL}/api/v1/sandboxes/{SANDBOX_ID}/settings/secrets/GITHUB_TOKEN" + ) + assert gh_secret.headers == {"X-Session-API-Key": SESSION_KEY} + assert gh_secret.description == "GitHub token" + + def test_get_secrets_filters_by_name(self, mock_workspace): + """get_secrets(names=[...]) filters client-side.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "secrets": [ + {"name": "GITHUB_TOKEN", "description": "GitHub token"}, + {"name": "MY_API_KEY", "description": None}, + ] + } + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_settings_request", return_value=mock_response + ): + secrets = mock_workspace.get_secrets(names=["GITHUB_TOKEN"]) + + assert len(secrets) == 1 + assert "GITHUB_TOKEN" in secrets + assert "MY_API_KEY" not in secrets + + def test_get_secrets_empty(self, mock_workspace): + """Empty secrets list returns empty dict.""" + mock_response = MagicMock() + mock_response.json.return_value = {"secrets": []} + mock_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, "_send_settings_request", return_value=mock_response + ): + secrets = mock_workspace.get_secrets() + + assert secrets == {} + + def test_get_secrets_raises_when_no_sandbox(self, mock_workspace): + """get_secrets raises RuntimeError when sandbox is not running.""" + mock_workspace._sandbox_id = None + with pytest.raises(RuntimeError, match="Sandbox is not running"): + mock_workspace.get_secrets() + + +class TestRetry: + """Tests for retry behaviour on get_llm and get_secrets.""" + + def test_get_llm_retries_on_server_error(self, mock_workspace): + """get_llm retries on 5xx and succeeds on the next attempt.""" + error_response = httpx.Response( + status_code=502, request=httpx.Request("GET", "http://x") + ) + ok_response = MagicMock() + ok_response.json.return_value = { + "llm_model": "gpt-4o", + "llm_api_key": "sk-ok", + "llm_base_url": None, + } + ok_response.raise_for_status = MagicMock() + + with patch.object( + mock_workspace, + "_send_api_request", + side_effect=[ + httpx.HTTPStatusError( + "Bad Gateway", + request=error_response.request, + response=error_response, + ), + ok_response, + ], + ): + llm = mock_workspace.get_llm() + + assert llm.model == "gpt-4o" + + def test_get_llm_no_retry_on_client_error(self, mock_workspace): + """get_llm does NOT retry on 4xx errors.""" + error_response = httpx.Response( + status_code=401, request=httpx.Request("GET", "http://x") + ) + + with patch.object( + mock_workspace, + "_send_api_request", + side_effect=httpx.HTTPStatusError( + "Unauthorized", + request=error_response.request, + response=error_response, + ), + ): + with pytest.raises(httpx.HTTPStatusError): + mock_workspace.get_llm() + + def test_get_secrets_retries_on_server_error(self, mock_workspace): + """_send_settings_request retries on 5xx for get_secrets.""" + ok_response = MagicMock() + ok_response.json.return_value = { + "secrets": [{"name": "TOK", "description": None}] + } + ok_response.raise_for_status = MagicMock() + + with patch("httpx.Client") as MockClient: + mock_client = MagicMock() + MockClient.return_value.__enter__ = MagicMock(return_value=mock_client) + MockClient.return_value.__exit__ = MagicMock(return_value=False) + mock_client.request.side_effect = [ + httpx.Response( + status_code=503, + request=httpx.Request("GET", "http://x"), + ), + ok_response, + ] + # The first call raises on raise_for_status, second succeeds + secrets = mock_workspace.get_secrets() + + assert "TOK" in secrets