Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3e60a37
feat: add saas_runtime_mode to OpenHandsCloudWorkspace
openhands-agent Mar 18, 2026
9fc6b00
fix: make cloud_api_url/key required in all modes, ref RFC
openhands-agent Mar 18, 2026
210bb64
feat: expose SDK packages to system Python in source images
openhands-agent Mar 18, 2026
336f7ea
Revert "feat: expose SDK packages to system Python in source images"
openhands-agent Mar 18, 2026
b3841c4
feat: add completion callback on __exit__ for automation service
openhands-agent Mar 19, 2026
975647d
feat: read agent_server_port from AGENT_SERVER_PORT env var
openhands-agent Mar 23, 2026
e0eca29
Revert "feat: read agent_server_port from AGENT_SERVER_PORT env var"
openhands-agent Mar 23, 2026
3f81ef3
fix: set _sandbox_id and _session_api_key in saas_runtime_mode
openhands-agent Mar 23, 2026
71f6641
fix: apply ruff-format formatting fixes
openhands-agent Mar 23, 2026
78f9f10
Merge branch 'main' into feat/saas-runtime-mode
xingyaoww Mar 23, 2026
13a4aa6
fix: set api_key in saas_runtime_mode for agent-server auth
openhands-agent Mar 23, 2026
8e797ec
security: strip SESSION_API_KEY from subprocess environment
openhands-agent Mar 23, 2026
4b6b46e
Merge branch 'main' into feat/saas-runtime-mode
malhotra5 Mar 23, 2026
a6fc20f
style: fix line length and formatting in test file
openhands-agent Mar 23, 2026
45a39f6
fix(workspace): add Authorization header to completion callback
openhands-agent Mar 25, 2026
54e02e3
refactor: rename saas_runtime_mode to local_agent_server_mode, read a…
openhands-agent Mar 25, 2026
66fabd1
Merge branch 'main' into feat/saas-runtime-mode
xingyaoww Mar 25, 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
14 changes: 14 additions & 0 deletions openhands-sdk/openhands/sdk/utils/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
logger = get_logger(__name__)


# Env vars that should not be exposed to subprocesses (e.g., bash commands
# executed by the agent). These credentials allow access to user secrets via
# the SaaS API and must remain isolated to the SDK's Python process.
_SENSITIVE_ENV_VARS = frozenset({"SESSION_API_KEY"})


def sanitized_env(
env: Mapping[str, str] | None = None,
) -> dict[str, str]:
Expand All @@ -19,6 +25,10 @@ def sanitized_env(
PyInstaller-based binaries rewrite ``LD_LIBRARY_PATH`` so their vendored
libraries win. This function restores the original value so that subprocess
will not use them.

Sensitive environment variables (e.g., ``SESSION_API_KEY``) are stripped
to prevent LLM-driven agents from accessing credentials via terminal
commands.
"""

base_env: dict[str, str]
Expand All @@ -27,6 +37,10 @@ def sanitized_env(
else:
base_env = dict(env)

# Strip sensitive env vars to prevent agent access via bash commands
for key in _SENSITIVE_ENV_VARS:
base_env.pop(key, None)

if "LD_LIBRARY_PATH_ORIG" in base_env:
origin = base_env["LD_LIBRARY_PATH_ORIG"]
if origin:
Expand Down
178 changes: 164 additions & 14 deletions openhands-workspace/openhands/workspace/cloud/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any
from urllib.request import urlopen

Expand All @@ -26,6 +27,9 @@
# Number of retry attempts for transient API failures
_MAX_RETRIES = 3

# Default port the agent-server listens on inside a Cloud Runtime
DEFAULT_AGENT_SERVER_PORT = 60000
Comment thread
xingyaoww marked this conversation as resolved.


def _is_retryable_error(error: BaseException) -> bool:
"""Return True for transient errors that are worth retrying."""
Expand All @@ -40,6 +44,11 @@ class OpenHandsCloudWorkspace(RemoteWorkspace):
This workspace connects to OpenHands Cloud (app.all-hands.dev) to provision
and manage sandboxed environments for agent execution.

When ``local_agent_server_mode=True``, the workspace assumes it is already
running inside an OpenHands Cloud Runtime sandbox. Instead of creating or
managing a sandbox via the Cloud API it connects directly to the local
agent-server at ``http://localhost:<agent_server_port>``.

Example:
workspace = OpenHandsCloudWorkspace(
cloud_api_url="https://app.all-hands.dev",
Expand All @@ -52,6 +61,13 @@ class OpenHandsCloudWorkspace(RemoteWorkspace):
cloud_api_key="your-api-key",
sandbox_spec_id="ghcr.io/openhands/agent-server:main-python",
)

# Running inside an OpenHands Cloud Runtime (local agent-server mode)
workspace = OpenHandsCloudWorkspace(
local_agent_server_mode=True,
cloud_api_url="https://app.all-hands.dev",
cloud_api_key=os.environ["OPENHANDS_API_KEY"],
)
"""

# Parent fields
Expand All @@ -61,46 +77,74 @@ class OpenHandsCloudWorkspace(RemoteWorkspace):
)
host: str = Field(
default="undefined",
description="The agent server URL. Set automatically after sandbox starts.",
description=("The agent server URL. Set automatically after sandbox starts."),
)

# Local agent-server mode
local_agent_server_mode: bool = Field(
default=False,
description=(
"When True, assume the SDK is running inside an OpenHands Cloud "
"Runtime and connect to the local agent-server instead of "
"provisioning a sandbox via the Cloud API."
),
)
agent_server_port: int = Field(
default=DEFAULT_AGENT_SERVER_PORT,
description=(
"Port of the local agent-server. "
"Only used when local_agent_server_mode=True."
),
)

# Cloud API fields
cloud_api_url: str = Field(
description="Base URL of OpenHands Cloud API (e.g., https://app.all-hands.dev)"
description=(
"Base URL of OpenHands Cloud API "
"(e.g., https://app.all-hands.dev). "
"Required in all modes — used for get_llms / get_secrets."
),
)
cloud_api_key: str = Field(
description="API key for authenticating with OpenHands Cloud"
description=(
"API key for authenticating with OpenHands Cloud. "
"Required in all modes — used for get_llms / get_secrets."
),
)
sandbox_spec_id: str | None = Field(
default=None,
description="Optional sandbox specification ID (e.g., container image)",
description=("Optional sandbox specification ID (e.g., container image)"),
)

# Lifecycle options
init_timeout: float = Field(
default=300.0, description="Sandbox initialization timeout in seconds"
default=300.0,
description="Sandbox initialization timeout in seconds",
)
api_timeout: float = Field(
default=60.0, description="API request timeout in seconds"
)
keep_alive: bool = Field(
default=False,
description="If True, keep sandbox alive on cleanup instead of deleting",
description=("If True, keep sandbox alive on cleanup instead of deleting"),
)

# Sandbox ID - can be provided to resume an existing sandbox
sandbox_id: str | None = Field(
default=None,
description=(
"Optional sandbox ID to resume. If provided, the workspace will "
"attempt to resume the existing sandbox instead of creating a new one."
"attempt to resume the existing sandbox instead of creating a "
"new one."
),
)

# Private state
_sandbox_id: str | None = PrivateAttr(default=None)
_session_api_key: str | None = PrivateAttr(default=None)
_exposed_urls: list[dict[str, Any]] | None = PrivateAttr(default=None)
_automation_callback_url: str | None = PrivateAttr(default=None)
_automation_run_id: str | None = PrivateAttr(default=None)

@property
def client(self) -> httpx.Client:
Expand Down Expand Up @@ -131,12 +175,67 @@ def model_post_init(self, context: Any) -> None:
"""Set up the sandbox and initialize the workspace."""
self.cloud_api_url = self.cloud_api_url.rstrip("/")

try:
self._start_sandbox()
super().model_post_init(context)
except Exception:
self.cleanup()
raise
if self.local_agent_server_mode:
self._init_local_agent_server_mode()
else:
try:
self._start_sandbox()
super().model_post_init(context)
except Exception:
self.cleanup()
raise

def _init_local_agent_server_mode(self) -> None:
"""Initialize in local agent-server mode — connect to local agent-server.

Reads sandbox identity and automation callback settings from
environment variables so that ``get_llm()`` and ``get_secrets()``
can call the Cloud API's sandbox-scoped settings endpoints.

Expected env vars (injected by the automation dispatcher):
``SANDBOX_ID`` — this sandbox's Cloud API identifier
``SESSION_API_KEY`` — session key for sandbox settings auth
``AUTOMATION_CALLBACK_URL`` — completion callback endpoint (optional)
``AUTOMATION_RUN_ID`` — run ID for callback payload (optional)

Falls back to ``OH_SESSION_API_KEYS_0`` (set by the runtime)
if ``SESSION_API_KEY`` is not present.
"""
port = os.environ.get("AGENT_SERVER_PORT", str(self.agent_server_port))
self.host = f"http://localhost:{port}"
logger.info(
f"Local agent-server mode: connecting to agent-server at {self.host}"
)

# Discover sandbox identity from env vars
self._sandbox_id = self.sandbox_id or os.environ.get("SANDBOX_ID")
self._session_api_key = os.environ.get(
"SESSION_API_KEY", os.environ.get("OH_SESSION_API_KEYS_0")
)

# Automation callback settings from env vars
self._automation_callback_url = os.environ.get("AUTOMATION_CALLBACK_URL")
self._automation_run_id = os.environ.get("AUTOMATION_RUN_ID")

if not self._sandbox_id:
logger.warning(
"SANDBOX_ID env var not set — get_llm()/get_secrets() "
"will not work. Set SANDBOX_ID or pass sandbox_id= to "
"the constructor."
)
if not self._session_api_key:
logger.warning(
"SESSION_API_KEY env var not set — sandbox settings "
"API calls will fail."
)

# Propagate to RemoteWorkspaceMixin.api_key so the shared HTTP
# client (used by RemoteConversation) includes X-Session-API-Key.
self.api_key = self._session_api_key

self.reset_client()
# Trigger parent mixin init (strips trailing slash, etc.)
super().model_post_init(None)

def _start_sandbox(self) -> None:
"""Start a new sandbox or resume an existing one via Cloud API.
Expand Down Expand Up @@ -352,7 +451,26 @@ def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Respo
return response

def cleanup(self) -> None:
"""Clean up the sandbox by deleting it."""
"""Clean up the sandbox by deleting it.

In local agent-server mode the sandbox is managed externally, so only
the HTTP client is closed.
"""
# Guard against __del__ on partially-constructed instances
# (e.g. when validation fails before all fields are initialised).
try:
local_mode = self.local_agent_server_mode
except AttributeError:
return

if local_mode:
try:
if self._client:
self._client.close()
except Exception:
pass
return

if not self._sandbox_id:
return

Expand Down Expand Up @@ -543,4 +661,36 @@ def __enter__(self) -> OpenHandsCloudWorkspace:
return self

def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self._send_completion_callback(exc_type, exc_val)
self.cleanup()

def _send_completion_callback(
self, exc_type: type | None, exc_val: BaseException | None
) -> None:
"""POST completion status to the automation service (best-effort).

Called by ``__exit__`` before ``cleanup()``. Does nothing when
``AUTOMATION_CALLBACK_URL`` env var was not set.
"""
try:
callback_url = self._automation_callback_url
except AttributeError:
return

if not callback_url:
return

status = "COMPLETED" if exc_type is None else "FAILED"
payload: dict[str, Any] = {"status": status}
if self._automation_run_id:
payload["run_id"] = self._automation_run_id
if exc_val is not None:
payload["error"] = str(exc_val)

try:
headers = {"Authorization": f"Bearer {self.cloud_api_key}"}
with httpx.Client(timeout=10.0) as cb_client:
resp = cb_client.post(callback_url, json=payload, headers=headers)
logger.info(f"Completion callback sent ({status}): {resp.status_code}")
except Exception as e:
logger.warning(f"Completion callback failed: {e}")
85 changes: 85 additions & 0 deletions tests/agent_server/test_terminal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,3 +488,88 @@ async def test_search_pagination(bash_service):
page1_ids = {event.id for event in page1.items}
page2_ids = {event.id for event in page2.items}
assert len(page1_ids.intersection(page2_ids)) == 0 # No overlap


@pytest.mark.asyncio
async def test_terminal_does_not_expose_session_api_key(bash_service, monkeypatch):
"""Verify SESSION_API_KEY is not accessible to bash commands.

This is a security test: SESSION_API_KEY grants access to user secrets via
the SaaS API. If an LLM-driven agent could read this env var via terminal
commands, it could exfiltrate all user secrets. The sanitized_env() function
must strip this variable before passing the environment to subprocesses.
"""
# Simulate the automation service injecting SESSION_API_KEY into os.environ
secret_value = "super-secret-session-key-12345"
monkeypatch.setenv("SESSION_API_KEY", secret_value)

collector = EventCollector()
await bash_service.subscribe_to_events(collector)

# An agent might try to read the env var via echo or printenv
request = ExecuteBashRequest(
command='echo "SESSION_API_KEY=$SESSION_API_KEY"',
cwd="/tmp",
)
command, task = await bash_service.start_bash_command(request)
await task

# Collect the output
assert len(collector.outputs) >= 1
combined_stdout = "".join(
output.stdout or ""
for output in sorted(collector.outputs, key=lambda x: x.order)
)

# The secret value should NOT appear in the output
assert secret_value not in combined_stdout, (
f"SESSION_API_KEY was exposed to terminal command! Output: {combined_stdout}"
)
# The env var should be empty/unset
assert (
"SESSION_API_KEY=$" in combined_stdout
or "SESSION_API_KEY=\n" in combined_stdout
), f"SESSION_API_KEY should be unset in subprocess. Output: {combined_stdout}"


@pytest.mark.asyncio
async def test_terminal_does_not_expose_session_api_key_via_env_command(
bash_service, monkeypatch
):
"""Verify SESSION_API_KEY doesn't appear in 'env' command output.

An agent might run 'env' or 'printenv' to discover available environment
variables. SESSION_API_KEY must not be visible.
"""
secret_value = "another-secret-key-67890"
monkeypatch.setenv("SESSION_API_KEY", secret_value)
# Also set a safe var to confirm env command works
monkeypatch.setenv("SAFE_TEST_VAR", "visible-value")

collector = EventCollector()
await bash_service.subscribe_to_events(collector)

request = ExecuteBashRequest(
command="env | grep -E '(SESSION_API_KEY|SAFE_TEST_VAR)' || true",
cwd="/tmp",
)
command, task = await bash_service.start_bash_command(request)
await task

assert len(collector.outputs) >= 1
combined_stdout = "".join(
output.stdout or ""
for output in sorted(collector.outputs, key=lambda x: x.order)
)

# SESSION_API_KEY should not appear at all
assert "SESSION_API_KEY" not in combined_stdout, (
f"SESSION_API_KEY appeared in env output! Output: {combined_stdout}"
)
assert secret_value not in combined_stdout, (
f"Secret value leaked! Output: {combined_stdout}"
)
# But SAFE_TEST_VAR should be visible (confirms env command worked)
assert "SAFE_TEST_VAR=visible-value" in combined_stdout, (
f"Safe var not found - env command may have failed. Output: {combined_stdout}"
)
Loading
Loading