-
Notifications
You must be signed in to change notification settings - Fork 189
feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance #2409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: workspace.get_llm() and get_secrets() for OpenHandsCloudWorkspace credential inheritance #2409
Changes from 16 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 5173810
feat: LLM api_key accepts SecretSource; workspace returns LookupSecre…
openhands-agent bba405a
feat: add env_headers to LookupSecret for sandbox-only secret resolution
openhands-agent 1615d98
simplify: revert LLM SecretSource widening, get_llm returns real LLM
openhands-agent 0f3c159
refactor: get_llm uses /users/me?expose_secrets=true instead of /sett…
openhands-agent 4f1756f
security: send X-Session-API-Key with get_llm() request
openhands-agent 5982d77
feat: add SaaS credential inheritance example for cloud workspace
openhands-agent c2a24ba
fix: sandbox DELETE uses correct path + query param; add .pr/ test ar…
openhands-agent c9fcada
fix: SecretSource serialization in update_secrets + sandbox DELETE path
openhands-agent fe40666
improve: test prompt now exercises secret values (print last 50%)
openhands-agent f934fa9
docs: update integration test report with complete findings
openhands-agent 13dd4bc
refactor: drop env_headers, use expose_secrets context instead
openhands-agent 4972ae9
docs: finalize test artifact with all results
openhands-agent 3deba1e
docs: update test artifact with unambiguous verification output
openhands-agent 6552831
docs: replace stale .pr/logs with final passing test output
openhands-agent 6f3c8de
fix: clarify SaaS server is custom build from PR #13383
openhands-agent 84770cd
chore: Remove PR-only artifacts [automated]
7fd6d48
refactor: narrow LookupSecret.get_value() return type to str
openhands-agent 10cbd94
fix: resolve CI failures — renumber example 09→10, fix E501 line-too-…
openhands-agent d5367f5
docs: add provider tokens e2e test results to .pr/
openhands-agent d8b4ac4
docs: add full stdout of example 10 (SaaS credentials e2e)
openhands-agent 8755c4d
rename: 10_cloud_workspace_saas_credentials → 10_cloud_workspace_shar…
openhands-agent 066dde6
We should not override LLM / simplify prompt
xingyaoww f9e0cd3
Simplify prompt
xingyaoww 15104c4
Merge branch 'main' into feat/cloud-workspace-get-llm-secrets
xingyaoww e69170a
chore: Remove PR-only artifacts [automated]
50f88ba
Add retry (up to 3 attempts) for get_llm and get_secrets API calls
openhands-agent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| # PR #2409 — Integration Test Report | ||
|
|
||
| ## Test: `workspace.get_llm()` and `workspace.get_secrets()` against staging | ||
|
|
||
| **Date:** 2026-03-16 | ||
| **Target:** `https://ohpr-13383-240.staging.all-hands.dev` (deploy PR [#3436](https://github.com/OpenHands/deploy/pull/3436)) | ||
| **Server PR:** [OpenHands/OpenHands#13383](https://github.com/OpenHands/OpenHands/pull/13383) (companion) | ||
| **SaaS server:** Custom build from [OpenHands/OpenHands#13383](https://github.com/OpenHands/OpenHands/pull/13383) (provides `/sandboxes/{id}/settings/secrets` endpoints) | ||
| **Sandbox agent-server:** `ghcr.io/openhands/agent-server:1.13.0-python` (stock — no SDK PR changes needed in sandbox) | ||
|
|
||
| ## Final Results (all passing ✅) | ||
|
|
||
| | Component | Status | Details | | ||
| |---|---|---| | ||
| | Sandbox provisioning | ✅ | Created, RUNNING in ~50s, cleaned up on exit | | ||
| | `workspace.get_llm()` | ✅ | Retrieves `litellm_proxy/minimax-m2.5` + api_key + base_url from SaaS | | ||
| | `workspace.get_secrets()` | ✅ | Discovers `['DUMMY_1', 'DUMMY_2']` via `GET /sandboxes/{id}/settings/secrets` | | ||
| | `update_secrets(LookupSecret)` | ✅ | LookupSecret with session key in `headers` survives serialization | | ||
| | Env vars exported inside sandbox | ✅ | `_export_envs` resolves LookupSecret → secrets appear as real env vars | | ||
| | Sandbox cleanup | ✅ | `DELETE /api/v1/sandboxes/{id}` succeeds | | ||
|
|
||
| ### Agent verification output (from inside the sandbox) | ||
|
|
||
| The agent ran the exact Python command we gave it. The output proves the secrets | ||
| were resolved by the `_export_envs` pipeline and exported as real env vars: | ||
|
|
||
| ``` | ||
| $ python3 -c "import os; v=os.environ.get('DUMMY_1',''); print(f'DUMMY_1: len={len(v)}, last_half={v[len(v)//2:]}')" && \ | ||
| python3 -c "import os; v=os.environ.get('DUMMY_2',''); print(f'DUMMY_2: len={len(v)}, last_half={v[len(v)//2:]}')" | ||
|
|
||
| DUMMY_1: len=14, last_half=ecret 1 | ||
| DUMMY_2: len=14, last_half=ecret 2 | ||
| ``` | ||
|
|
||
| Both secrets are 14 characters long, non-empty, and the second half matches | ||
| the expected values (`"Dummy secret 1"` → `"ecret 1"`, `"dummy secret 2"` → `"ecret 2"`). | ||
|
|
||
| ## Issues found and fixed during testing | ||
|
|
||
| ### 1. Sandbox DELETE 405 | ||
| `cleanup()` called `DELETE /api/v1/sandboxes?sandbox_id=X` (query param on collection route) → 405. | ||
| **Fix:** Changed to `DELETE /api/v1/sandboxes/{sandbox_id}?sandbox_id={sandbox_id}`. | ||
|
|
||
| ### 2. SecretStr serialization redacts headers | ||
| `SecretSource.model_dump(mode="json")` redacts `SecretStr` fields (returns `**********`). | ||
| `LookupSecret.headers` containing `X-Session-API-Key` was lost during `update_secrets()` serialization. | ||
| **Fix:** Added `context={"expose_secrets": True}` to `model_dump()` in `RemoteConversation.update_secrets()`. | ||
|
|
||
| ### 3. Removed `env_headers` (was unnecessary complexity) | ||
| Original design added `env_headers` field to `LookupSecret` so session key VALUE never appeared | ||
| in serialized JSON (only the env var NAME). This created a deployment dependency — the agent-server | ||
| image needed the new field, but the stock image didn't have it. | ||
| **Fix:** Dropped `env_headers` entirely. Using the existing `headers` field with `expose_secrets` | ||
| context is simpler and works with the stock agent-server image. No custom build or redeploy needed. | ||
| Net: **-35 lines, +10 lines**. |
Large diffs are not rendered by default.
Oops, something went wrong.
119 changes: 119 additions & 0 deletions
119
examples/02_remote_agent_server/09_cloud_workspace_saas_credentials.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| """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/09_cloud_workspace_saas_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. | ||
| # You can override any parameter, e.g. workspace.get_llm(model="gpt-4o") | ||
| llm_kwargs = {} | ||
| if os.getenv("LLM_MODEL"): | ||
| llm_kwargs["model"] = os.getenv("LLM_MODEL") | ||
| llm = workspace.get_llm(**llm_kwargs) | ||
| 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: | ||
| prompt = ( | ||
| "List the environment variables that start with SECRET_ or end with _TOKEN, " | ||
| "then write a short summary of what you find into SECRETS_CHECK.txt." | ||
| ) | ||
|
|
||
| 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.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.