Skip to content

Commit 06567f5

Browse files
Fix Remote Runtime API Authentication (#1090)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 865273c commit 06567f5

File tree

4 files changed

+89
-8
lines changed

4 files changed

+89
-8
lines changed

.github/workflows/run-examples.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,19 @@ jobs:
2424
runs-on: blacksmith-2vcpu-ubuntu-2404
2525
timeout-minutes: 60
2626
steps:
27+
- name: Wait for agent server to finish build
28+
uses: lewagon/wait-on-check-action@v1.4.1
29+
with:
30+
ref: ${{ github.event.pull_request.head.ref }}
31+
check-name: Build & Push (python-amd64)
32+
repo-token: ${{ secrets.GITHUB_TOKEN }}
33+
wait-interval: 10
34+
2735
- name: Checkout
2836
uses: actions/checkout@v5
37+
with:
38+
ref: ${{ github.event.pull_request.head.ref }}
39+
repository: ${{ github.event.pull_request.head.repo.full_name }}
2940

3041
- name: Install uv
3142
uses: astral-sh/setup-uv@v7
@@ -45,10 +56,12 @@ jobs:
4556
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
4657
LLM_MODEL: openhands/claude-haiku-4-5-20251001
4758
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
59+
RUNTIME_API_KEY: ${{ secrets.RUNTIME_API_KEY }}
4860
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4961
PR_NUMBER: ${{ github.event.pull_request.number }}
5062
REPO_OWNER: ${{ github.repository_owner }}
5163
REPO_NAME: ${{ github.event.repository.name }}
64+
GITHUB_SHA: ${{ github.event.pull_request.head.sha }}
5265
run: |
5366
# List of examples to test
5467
# Excluded examples:
@@ -85,6 +98,7 @@ jobs:
8598
"examples/02_remote_agent_server/01_convo_with_local_agent_server.py"
8699
"examples/02_remote_agent_server/02_convo_with_docker_sandboxed_server.py"
87100
"examples/02_remote_agent_server/03_browser_use_with_docker_sandboxed_server.py"
101+
"examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py"
88102
)
89103
90104
# GitHub API setup (only for PR events)

examples/02_remote_agent_server/04_convo_with_api_sandboxed_server.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import os
1515
import time
1616

17+
import requests
1718
from pydantic import SecretStr
1819

1920
from openhands.sdk import (
@@ -44,10 +45,43 @@
4445
logger.error("RUNTIME_API_KEY required")
4546
exit(1)
4647

48+
49+
def get_latest_commit_sha(
50+
repo: str = "OpenHands/software-agent-sdk", branch: str = "main"
51+
) -> str:
52+
"""
53+
Return the full SHA of the latest commit on `branch` for the given GitHub repo.
54+
Respects an optional GITHUB_TOKEN to avoid rate limits.
55+
"""
56+
url = f"https://api.github.com/repos/{repo}/commits/{branch}"
57+
headers = {}
58+
token = os.getenv("GITHUB_TOKEN") or os.getenv("GH_TOKEN")
59+
if token:
60+
headers["Authorization"] = f"Bearer {token}"
61+
62+
resp = requests.get(url, headers=headers, timeout=20)
63+
if resp.status_code != 200:
64+
raise RuntimeError(f"GitHub API error {resp.status_code}: {resp.text}")
65+
data = resp.json()
66+
sha = data.get("sha")
67+
if not sha:
68+
raise RuntimeError("Could not find commit SHA in GitHub response")
69+
logger.info(f"Latest commit on {repo} branch={branch} is {sha}")
70+
return sha
71+
72+
73+
# If GITHUB_SHA is set (e.g. running in CI of a PR), use that to ensure consistency
74+
# Otherwise, get the latest commit SHA from main branch (images are built on main)
75+
server_image_sha = os.getenv("GITHUB_SHA") or get_latest_commit_sha(
76+
"OpenHands/software-agent-sdk", "main"
77+
)
78+
server_image = f"ghcr.io/openhands/agent-server:{server_image_sha[:7]}-python-amd64"
79+
logger.info(f"Using server image: {server_image}")
80+
4781
with APIRemoteWorkspace(
4882
runtime_api_url=os.getenv("RUNTIME_API_URL", "https://runtime.eval.all-hands.dev"),
4983
runtime_api_key=runtime_api_key,
50-
server_image="ghcr.io/openhands/agent-server:main-python",
84+
server_image=server_image,
5185
) as workspace:
5286
agent = get_default_agent(llm=llm, cli_mode=True)
5387
received_events: list = []
@@ -78,5 +112,7 @@ def event_callback(event) -> None:
78112

79113
conversation.send_message("Great! Now delete that file.")
80114
conversation.run()
115+
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
116+
print(f"EXAMPLE_COST: {cost}")
81117
finally:
82118
conversation.close()

openhands-sdk/openhands/sdk/workspace/remote/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def client(self) -> httpx.Client:
4040
# - write: 10 seconds to send request
4141
# - pool: 10 seconds to get connection from pool
4242
timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
43-
client = httpx.Client(base_url=self.host, timeout=timeout)
43+
client = httpx.Client(
44+
base_url=self.host, timeout=timeout, headers=self._headers
45+
)
4446
self._client = client
4547
return client
4648

openhands-workspace/openhands/workspace/remote_api/workspace.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class APIRemoteWorkspace(RemoteWorkspace):
5454
default=1, description="Resource scaling (1, 2, 4, or 8)"
5555
)
5656
runtime_class: str | None = Field(
57-
default="sysbox", description="Runtime class (e.g., 'sysbox')"
57+
default="sysbox-runc", description="Runtime class (e.g., 'sysbox')"
5858
)
5959
init_timeout: float = Field(
6060
default=300.0, description="Runtime init timeout (seconds)"
@@ -71,6 +71,21 @@ class APIRemoteWorkspace(RemoteWorkspace):
7171
_runtime_url: str | None = PrivateAttr(default=None)
7272
_session_api_key: str | None = PrivateAttr(default=None)
7373

74+
@property
75+
def _api_headers(self):
76+
"""Headers for runtime API requests."
77+
78+
This is used to manage new container runtimes via Runtime API.
79+
80+
For actual interaction with the remote agent server, the
81+
`client` property is used, which includes the session API key
82+
defined by ._headers property.
83+
"""
84+
headers = {}
85+
if self.runtime_api_key:
86+
headers["X-API-Key"] = self.runtime_api_key
87+
return headers
88+
7489
def model_post_init(self, context: Any) -> None:
7590
"""Set up the remote runtime and initialize the workspace."""
7691
if self.resource_factor not in [1, 2, 4, 8]:
@@ -97,12 +112,18 @@ def _start_or_attach_to_runtime(self) -> None:
97112
logger.info(f"Runtime ready at {self._runtime_url}")
98113
self.host = self._runtime_url.rstrip("/")
99114
self.api_key = self._session_api_key
115+
self._client = None # Reset HTTP client with new host and API key
116+
_ = self.client # Initialize client by accessing the property
117+
assert self.client is not None
118+
assert self.client.base_url == self.host
100119

101120
def _check_existing_runtime(self) -> bool:
102121
"""Check if there's an existing runtime for this session."""
103122
try:
104123
resp = self._send_api_request(
105-
"GET", f"{self.runtime_api_url}/sessions/{self.session_id}"
124+
"GET",
125+
f"{self.runtime_api_url}/sessions/{self.session_id}",
126+
headers=self._api_headers,
106127
)
107128
data = resp.json()
108129
status = data.get("status")
@@ -149,6 +170,7 @@ def _start_runtime(self) -> None:
149170
f"{self.runtime_api_url}/start",
150171
json=payload,
151172
timeout=self.init_timeout,
173+
headers=self._api_headers,
152174
)
153175
self._parse_runtime_response(resp)
154176
logger.info(f"Runtime {self._runtime_id} at {self._runtime_url}")
@@ -160,6 +182,7 @@ def _resume_runtime(self) -> None:
160182
f"{self.runtime_api_url}/resume",
161183
json={"runtime_id": self._runtime_id},
162184
timeout=self.init_timeout,
185+
headers=self._api_headers,
163186
)
164187
self._parse_runtime_response(resp)
165188

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

185208
resp = self._send_api_request(
186-
"GET", f"{self.runtime_api_url}/sessions/{self.session_id}"
209+
"GET",
210+
f"{self.runtime_api_url}/sessions/{self.session_id}",
211+
headers=self._api_headers,
187212
)
188213
data = resp.json()
189214
pod_status = data.get("pod_status", "").lower()
@@ -244,12 +269,15 @@ def _wait_until_runtime_alive(self) -> None:
244269
def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
245270
"""Send an API request with error handling."""
246271
logger.debug(f"Sending {method} request to {url}")
247-
logger.debug(f"Client headers: {self._headers}")
272+
logger.debug(f"Request kwargs: {kwargs.keys()}")
273+
248274
response = self.client.request(method, url, **kwargs)
249275
try:
250276
response.raise_for_status()
251277
except httpx.HTTPStatusError:
252-
logger.debug(f"Request headers: {response.request.headers}")
278+
# Log only header keys, not values (to avoid exposing API keys)
279+
header_keys = list(response.request.headers.keys())
280+
logger.debug(f"Request header keys: {header_keys}")
253281
try:
254282
error_detail = response.json()
255283
logger.info(f"API request failed: {error_detail}")
@@ -274,9 +302,10 @@ def cleanup(self) -> None:
274302
f"{self.runtime_api_url}/{action}",
275303
json={"runtime_id": self._runtime_id},
276304
timeout=30.0,
305+
headers=self._api_headers,
277306
)
278307
except Exception as e:
279-
logger.error(f"Cleanup error: {e}")
308+
logger.warning(f"Cleanup error: {e}")
280309
finally:
281310
self._runtime_id = None
282311
self._runtime_url = None

0 commit comments

Comments
 (0)