Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c53bee7
fix remote api workspace
xingyaoww Nov 6, 2025
f2d5322
figure out headers
xingyaoww Nov 6, 2025
28fd153
fix auth for agent-server requests
xingyaoww Nov 7, 2025
363dd4a
fetch latest agent-server image in the example
xingyaoww Nov 7, 2025
be97d23
add to example tests
xingyaoww Nov 7, 2025
bec7b32
Merge branch 'main' into xw/remote-api-auth
xingyaoww Nov 7, 2025
2c3dc35
fix example name
xingyaoww Nov 7, 2025
198fb80
add secret
xingyaoww Nov 7, 2025
568fe4f
make test example wait after agent server finishes building image
xingyaoww Nov 7, 2025
12406c6
include github sha for run examples
xingyaoww Nov 7, 2025
cdb9fdf
fix check name
xingyaoww Nov 7, 2025
3e4a065
remove guard
xingyaoww Nov 7, 2025
314ee93
tweak example
xingyaoww Nov 7, 2025
248b235
Merge branch 'main' into xw/remote-api-auth
xingyaoww Nov 7, 2025
737d97e
use logger
xingyaoww Nov 7, 2025
8e1f2cb
throw warnings instead of error
xingyaoww Nov 7, 2025
7ec292d
fix sha and ref
xingyaoww Nov 7, 2025
1cb6f0c
Merge branch 'main' into xw/remote-api-auth
xingyaoww Nov 7, 2025
4f02cfe
fix check name
xingyaoww Nov 7, 2025
b349ea5
fix checkout
xingyaoww Nov 7, 2025
0fab162
update check name
xingyaoww Nov 7, 2025
422c539
revert check name
xingyaoww Nov 7, 2025
0416c6a
hardcode arch
xingyaoww Nov 7, 2025
bbae87d
re-enable all other examples
xingyaoww Nov 7, 2025
67f73dd
print example code
xingyaoww Nov 7, 2025
3701e0f
Revert "re-enable all other examples"
xingyaoww Nov 7, 2025
b0fed18
Revert "Revert "re-enable all other examples""
xingyaoww Nov 7, 2025
6c9d97e
Redact sensitive headers from debug logging
openhands-agent Nov 7, 2025
93598d8
Apply suggestion from @xingyaoww
xingyaoww Nov 7, 2025
76980b3
fix pre-commit
xingyaoww Nov 7, 2025
d1e9f7f
Merge branch 'main' into xw/remote-api-auth
xingyaoww Nov 7, 2025
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 .github/workflows/run-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,19 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 60
steps:
- name: Wait for agent server to finish build
uses: lewagon/[email protected]
with:
ref: ${{ github.event.pull_request.head.ref }}
check-name: Build & Push (python-amd64)
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10

- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand All @@ -45,10 +56,12 @@ jobs:
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_MODEL: openhands/claude-haiku-4-5-20251001
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
RUNTIME_API_KEY: ${{ secrets.RUNTIME_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
run: |
# List of examples to test
# Excluded examples:
Expand Down Expand Up @@ -85,6 +98,7 @@ jobs:
"examples/02_remote_agent_server/01_convo_with_local_agent_server.py"
"examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py"
"examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py"
"examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py"
)

# GitHub API setup (only for PR events)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import os
import time

import requests
from pydantic import SecretStr

from openhands.sdk import (
Expand Down Expand Up @@ -44,10 +45,43 @@
logger.error("RUNTIME_API_KEY required")
exit(1)


def get_latest_commit_sha(
repo: str = "OpenHands/software-agent-sdk", branch: str = "main"
) -> str:
"""
Return the full SHA of the latest commit on `branch` for the given GitHub repo.
Respects an optional GITHUB_TOKEN to avoid rate limits.
"""
url = f"https://api.github.com/repos/{repo}/commits/{branch}"
headers = {}
token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"

resp = requests.get(url, headers=headers, timeout=20)
if resp.status_code != 200:
raise RuntimeError(f"GitHub API error {resp.status_code}: {resp.text}")
data = resp.json()
sha = data.get("sha")
if not sha:
raise RuntimeError("Could not find commit SHA in GitHub response")
logger.info(f"Latest commit on {repo} branch={branch} is {sha}")
return sha


# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
# Otherwise, get the latest commit SHA from main branch (images are built on main)
server_image_sha = os.getenv("GITHUB_SHA") or get_latest_commit_sha(
"OpenHands/software-agent-sdk", "main"
)
server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64"
logger.info(f"Using server image: {server_image}")
Comment on lines +73 to +79
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this is necessarily blocking, but this is a bit complex for an example. Maybe we could move this off into some other utility function in another file?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands please create a new github issue tracking this - we can simplify this after we are able to customize image pull policy on runtime API (after https://github.com/OpenHands/runtime-api/pull/356)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

I've successfully created a GitHub issue to track the simplification work as requested.

Issue Created: #1104

Title: "Simplify image resolution logic in remote agent server example after runtime-api image pull policy support"

What the issue tracks:

  • The complex get_latest_commit_sha() function and related image resolution logic in examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py (lines 49-79)
  • Plans to simplify this code once runtime-api PR Unify tool initialization — Step 1: global registry + lazy materialization #356 is merged, which will add support for customizable image pull policies
  • The simplification will remove the GitHub API querying logic and use simpler, stable image tags instead
  • This will make the example more focused on demonstrating core functionality rather than workaround logic

The issue has been labeled with enhancement and technical-debt tags, includes references to the runtime-api PR #356 and the current PR #1090, and contains a task checklist for tracking the work.

View full conversation


with APIRemoteWorkspace(
runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"),
runtime_api_key=runtime_api_key,
server_image="ghcr.io/openhands/agent-server:main-python",
server_image=server_image,
) as workspace:
agent = get_default_agent(llm=llm, cli_mode=True)
received_events: list = []
Expand Down Expand Up @@ -78,5 +112,7 @@ def event_callback(event) -> None:

conversation.send_message("Great! Now delete that file.")
conversation.run()
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"EXAMPLE_COST: {cost}")
finally:
conversation.close()
4 changes: 3 additions & 1 deletion openhands-sdk/openhands/sdk/workspace/remote/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ def client(self) -> httpx.Client:
# - write: 10 seconds to send request
# - pool: 10 seconds to get connection from pool
timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
client = httpx.Client(base_url=self.host, timeout=timeout)
client = httpx.Client(
base_url=self.host, timeout=timeout, headers=self._headers
)
self._client = client
return client

Expand Down
41 changes: 35 additions & 6 deletions openhands-workspace/openhands/workspace/remote_api/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class APIRemoteWorkspace(RemoteWorkspace):
default=1, description="Resource scaling (1, 2, 4, or 8)"
)
runtime_class: str | None = Field(
default="sysbox", description="Runtime class (e.g., 'sysbox')"
default="sysbox-runc", description="Runtime class (e.g., 'sysbox')"
)
init_timeout: float = Field(
default=300.0, description="Runtime init timeout (seconds)"
Expand All @@ -71,6 +71,21 @@ class APIRemoteWorkspace(RemoteWorkspace):
_runtime_url: str | None = PrivateAttr(default=None)
_session_api_key: str | None = PrivateAttr(default=None)

@property
def _api_headers(self):
"""Headers for runtime API requests."

This is used to manage new container runtimes via Runtime API.

For actual interaction with the remote agent server, the
`client` property is used, which includes the session API key
defined by ._headers property.
"""
headers = {}
if self.runtime_api_key:
headers["X-API-Key"] = self.runtime_api_key
return headers

def model_post_init(self, context: Any) -> None:
"""Set up the remote runtime and initialize the workspace."""
if self.resource_factor not in [1, 2, 4, 8]:
Expand All @@ -97,12 +112,18 @@ def _start_or_attach_to_runtime(self) -> None:
logger.info(f"Runtime ready at {self._runtime_url}")
self.host = self._runtime_url.rstrip("/")
self.api_key = self._session_api_key
self._client = None # Reset HTTP client with new host and API key
_ = self.client # Initialize client by accessing the property
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks a bit weird, maybe error-prone, though I see where it's coming from...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands review the codebase and understand how we can do this better. Then create an issue about it as we can resolve it in a future PR

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

I've reviewed the codebase and created a GitHub issue documenting the architectural concern identified in PR #1090.

What I Did

  1. Analyzed the codebase to understand the authentication pattern in APIRemoteWorkspace (lines 114-116 in openhands-workspace/openhands/workspace/remote_api/workspace.py)

  2. Identified the issue: The current pattern for updating HTTP client credentials is fragile and error-prone:

    self.api_key = self._session_api_key
    self._client = None  # Reset HTTP client with new host and API key
    _ = self.client  # Initialize client by accessing the property
  3. Created GitHub Issue Refactor: Improve HTTP client credential update pattern in Remote Workspace #1101: Refactor: Improve HTTP client credential update pattern in Remote Workspace

Key Problems Documented

The issue describes why this pattern is concerning:

  • Non-obvious side effects: Accessing .client triggers lazy initialization
  • Order dependency: Operations must happen in exact sequence
  • Fragility: Changes to parent class could break this silently
  • Code smell: Using _ = self.client just for side effects
  • Mixed concerns: Two authentication mechanisms (Runtime API vs Agent Server) are intertwined

Proposed Solutions

I documented four potential solutions in the issue, with Option 2 (dynamic client recreation) recommended as it:

  • Requires minimal code changes
  • Makes credential updates transparent
  • Is backwards compatible
  • Handles both credential AND host changes automatically

Option 3 (separate clients for Runtime API vs Agent Server) would be ideal for a larger refactoring to make separation of concerns explicit.

No Code Changes Made

As requested, I did not modify any code - this analysis is captured in the GitHub issue for future resolution in a separate PR.

View full conversation

assert self.client is not None
assert self.client.base_url == self.host

def _check_existing_runtime(self) -> bool:
"""Check if there's an existing runtime for this session."""
try:
resp = self._send_api_request(
"GET", f"{self.runtime_api_url}/sessions/{self.session_id}"
"GET",
f"{self.runtime_api_url}/sessions/{self.session_id}",
headers=self._api_headers,
)
data = resp.json()
status = data.get("status")
Expand Down Expand Up @@ -149,6 +170,7 @@ def _start_runtime(self) -> None:
f"{self.runtime_api_url}/start",
json=payload,
timeout=self.init_timeout,
headers=self._api_headers,
)
self._parse_runtime_response(resp)
logger.info(f"Runtime {self._runtime_id} at {self._runtime_url}")
Expand All @@ -160,6 +182,7 @@ def _resume_runtime(self) -> None:
f"{self.runtime_api_url}/resume",
json={"runtime_id": self._runtime_id},
timeout=self.init_timeout,
headers=self._api_headers,
)
self._parse_runtime_response(resp)

Expand All @@ -183,7 +206,9 @@ def _wait_until_runtime_alive(self) -> None:
logger.info("Waiting for runtime to become alive...")

resp = self._send_api_request(
"GET", f"{self.runtime_api_url}/sessions/{self.session_id}"
"GET",
f"{self.runtime_api_url}/sessions/{self.session_id}",
headers=self._api_headers,
)
data = resp.json()
pod_status = data.get("pod_status", "").lower()
Expand Down Expand Up @@ -244,12 +269,15 @@ def _wait_until_runtime_alive(self) -> None:
def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
"""Send an API request with error handling."""
logger.debug(f"Sending {method} request to {url}")
logger.debug(f"Client headers: {self._headers}")
logger.debug(f"Request kwargs: {kwargs.keys()}")

response = self.client.request(method, url, **kwargs)
try:
response.raise_for_status()
except httpx.HTTPStatusError:
logger.debug(f"Request headers: {response.request.headers}")
# Log only header keys, not values (to avoid exposing API keys)
header_keys = list(response.request.headers.keys())
logger.debug(f"Request header keys: {header_keys}")
try:
error_detail = response.json()
logger.info(f"API request failed: {error_detail}")
Expand All @@ -274,9 +302,10 @@ def cleanup(self) -> None:
f"{self.runtime_api_url}/{action}",
json={"runtime_id": self._runtime_id},
timeout=30.0,
headers=self._api_headers,
)
except Exception as e:
logger.error(f"Cleanup error: {e}")
logger.warning(f"Cleanup error: {e}")
finally:
self._runtime_id = None
self._runtime_url = None
Expand Down
Loading