Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cdee972
feat: add get_llm() and get_secrets() to OpenHandsCloudWorkspace
openhands-agent Mar 13, 2026
5173810
feat: LLM api_key accepts SecretSource; workspace returns LookupSecre…
openhands-agent Mar 13, 2026
bba405a
feat: add env_headers to LookupSecret for sandbox-only secret resolution
openhands-agent Mar 13, 2026
1615d98
simplify: revert LLM SecretSource widening, get_llm returns real LLM
openhands-agent Mar 13, 2026
0f3c159
refactor: get_llm uses /users/me?expose_secrets=true instead of /sett…
openhands-agent Mar 13, 2026
4f1756f
security: send X-Session-API-Key with get_llm() request
openhands-agent Mar 13, 2026
5982d77
feat: add SaaS credential inheritance example for cloud workspace
openhands-agent Mar 16, 2026
c2a24ba
fix: sandbox DELETE uses correct path + query param; add .pr/ test ar…
openhands-agent Mar 16, 2026
c9fcada
fix: SecretSource serialization in update_secrets + sandbox DELETE path
openhands-agent Mar 16, 2026
fe40666
improve: test prompt now exercises secret values (print last 50%)
openhands-agent Mar 16, 2026
f934fa9
docs: update integration test report with complete findings
openhands-agent Mar 16, 2026
13dd4bc
refactor: drop env_headers, use expose_secrets context instead
openhands-agent Mar 16, 2026
4972ae9
docs: finalize test artifact with all results
openhands-agent Mar 16, 2026
3deba1e
docs: update test artifact with unambiguous verification output
openhands-agent Mar 16, 2026
6552831
docs: replace stale .pr/logs with final passing test output
openhands-agent Mar 16, 2026
6f3c8de
fix: clarify SaaS server is custom build from PR #13383
openhands-agent Mar 16, 2026
84770cd
chore: Remove PR-only artifacts [automated]
Mar 16, 2026
7fd6d48
refactor: narrow LookupSecret.get_value() return type to str
openhands-agent Mar 16, 2026
10cbd94
fix: resolve CI failures — renumber example 09→10, fix E501 line-too-…
openhands-agent Mar 16, 2026
d5367f5
docs: add provider tokens e2e test results to .pr/
openhands-agent Mar 17, 2026
d8b4ac4
docs: add full stdout of example 10 (SaaS credentials e2e)
openhands-agent Mar 17, 2026
8755c4d
rename: 10_cloud_workspace_saas_credentials → 10_cloud_workspace_shar…
openhands-agent Mar 17, 2026
066dde6
We should not override LLM / simplify prompt
xingyaoww Mar 17, 2026
f9e0cd3
Simplify prompt
xingyaoww Mar 17, 2026
15104c4
Merge branch 'main' into feat/cloud-workspace-get-llm-secrets
xingyaoww Mar 17, 2026
e69170a
chore: Remove PR-only artifacts [automated]
Mar 17, 2026
50f88ba
Add retry (up to 3 attempts) for get_llm and get_secrets API calls
openhands-agent Mar 17, 2026
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
Original file line number Diff line number Diff line change
@@ -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.")
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion openhands-sdk/openhands/sdk/secret/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 175 additions & 3 deletions openhands-workspace/openhands/workspace/cloud/workspace.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down
Loading
Loading