openhands-workspace 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """OpenHands Workspace - Docker and container-based workspace implementations."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from openhands.sdk.workspace import PlatformType, TargetType
6
+
7
+ from .cloud import OpenHandsCloudWorkspace
8
+ from .docker import DockerWorkspace
9
+ from .remote_api import APIRemoteWorkspace
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from .docker import DockerDevWorkspace
14
+
15
+ __all__ = [
16
+ "APIRemoteWorkspace",
17
+ "DockerDevWorkspace",
18
+ "DockerWorkspace",
19
+ "OpenHandsCloudWorkspace",
20
+ "PlatformType",
21
+ "TargetType",
22
+ ]
23
+
24
+
25
+ def __getattr__(name: str):
26
+ """Lazy import DockerDevWorkspace to avoid build module imports."""
27
+ if name == "DockerDevWorkspace":
28
+ from .docker import DockerDevWorkspace
29
+
30
+ return DockerDevWorkspace
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,6 @@
1
+ """OpenHands Cloud workspace implementation."""
2
+
3
+ from .workspace import OpenHandsCloudWorkspace
4
+
5
+
6
+ __all__ = ["OpenHandsCloudWorkspace"]
@@ -0,0 +1,314 @@
1
+ """OpenHands Cloud workspace implementation using Cloud API."""
2
+
3
+ from typing import Any
4
+ from urllib.request import urlopen
5
+
6
+ import httpx
7
+ import tenacity
8
+ from pydantic import Field, PrivateAttr
9
+
10
+ from openhands.sdk.logger import get_logger
11
+ from openhands.sdk.workspace.remote.base import RemoteWorkspace
12
+
13
+
14
+ logger = get_logger(__name__)
15
+
16
+ # Standard exposed URL names from OpenHands Cloud
17
+ AGENT_SERVER = "AGENT_SERVER"
18
+
19
+
20
+ class OpenHandsCloudWorkspace(RemoteWorkspace):
21
+ """Remote workspace using OpenHands Cloud API.
22
+
23
+ This workspace connects to OpenHands Cloud (app.all-hands.dev) to provision
24
+ and manage sandboxed environments for agent execution.
25
+
26
+ Example:
27
+ workspace = OpenHandsCloudWorkspace(
28
+ cloud_api_url="https://app.all-hands.dev",
29
+ cloud_api_key="your-api-key",
30
+ )
31
+
32
+ # With custom sandbox spec
33
+ workspace = OpenHandsCloudWorkspace(
34
+ cloud_api_url="https://app.all-hands.dev",
35
+ cloud_api_key="your-api-key",
36
+ sandbox_spec_id="ghcr.io/openhands/agent-server:main-python",
37
+ )
38
+ """
39
+
40
+ # Parent fields
41
+ working_dir: str = Field(
42
+ default="/workspace/project",
43
+ description="Working directory inside the sandbox",
44
+ )
45
+ host: str = Field(
46
+ default="undefined",
47
+ description="The agent server URL. Set automatically after sandbox starts.",
48
+ )
49
+
50
+ # Cloud API fields
51
+ cloud_api_url: str = Field(
52
+ description="Base URL of OpenHands Cloud API (e.g., https://app.all-hands.dev)"
53
+ )
54
+ cloud_api_key: str = Field(
55
+ description="API key for authenticating with OpenHands Cloud"
56
+ )
57
+ sandbox_spec_id: str | None = Field(
58
+ default=None,
59
+ description="Optional sandbox specification ID (e.g., container image)",
60
+ )
61
+
62
+ # Lifecycle options
63
+ init_timeout: float = Field(
64
+ default=300.0, description="Sandbox initialization timeout in seconds"
65
+ )
66
+ api_timeout: float = Field(
67
+ default=60.0, description="API request timeout in seconds"
68
+ )
69
+ keep_alive: bool = Field(
70
+ default=False,
71
+ description="If True, keep sandbox alive on cleanup instead of deleting",
72
+ )
73
+
74
+ # Private state
75
+ _sandbox_id: str | None = PrivateAttr(default=None)
76
+ _session_api_key: str | None = PrivateAttr(default=None)
77
+ _exposed_urls: list[dict[str, Any]] | None = PrivateAttr(default=None)
78
+
79
+ @property
80
+ def client(self) -> httpx.Client:
81
+ """Override client property to use api_timeout for HTTP requests."""
82
+ client = self._client
83
+ if client is None:
84
+ timeout = httpx.Timeout(
85
+ connect=10.0,
86
+ read=self.api_timeout,
87
+ write=10.0,
88
+ pool=10.0,
89
+ )
90
+ client = httpx.Client(
91
+ base_url=self.host, timeout=timeout, headers=self._headers
92
+ )
93
+ self._client = client
94
+ return client
95
+
96
+ @property
97
+ def _api_headers(self) -> dict[str, str]:
98
+ """Headers for Cloud API requests.
99
+
100
+ Uses Bearer token authentication as per OpenHands Cloud API.
101
+ """
102
+ return {"Authorization": f"Bearer {self.cloud_api_key}"}
103
+
104
+ def model_post_init(self, context: Any) -> None:
105
+ """Set up the sandbox and initialize the workspace."""
106
+ self.cloud_api_url = self.cloud_api_url.rstrip("/")
107
+
108
+ try:
109
+ self._start_sandbox()
110
+ super().model_post_init(context)
111
+ except Exception:
112
+ self.cleanup()
113
+ raise
114
+
115
+ def _start_sandbox(self) -> None:
116
+ """Start a new sandbox via Cloud API."""
117
+ logger.info("Starting sandbox via OpenHands Cloud API...")
118
+
119
+ # Build request params
120
+ params: dict[str, str] = {}
121
+ if self.sandbox_spec_id:
122
+ params["sandbox_spec_id"] = self.sandbox_spec_id
123
+
124
+ # POST /api/v1/sandboxes to start a new sandbox
125
+ resp = self._send_api_request(
126
+ "POST",
127
+ f"{self.cloud_api_url}/api/v1/sandboxes",
128
+ params=params if params else None,
129
+ timeout=self.init_timeout,
130
+ )
131
+ data = resp.json()
132
+
133
+ self._sandbox_id = data["id"]
134
+ self._session_api_key = data.get("session_api_key")
135
+ logger.info(
136
+ f"Sandbox {self._sandbox_id} created, waiting for it to be ready..."
137
+ )
138
+
139
+ # Wait for sandbox to become RUNNING
140
+ self._wait_until_sandbox_ready()
141
+
142
+ # Extract agent server URL from exposed_urls
143
+ agent_server_url = self._get_agent_server_url()
144
+ if not agent_server_url:
145
+ raise ValueError(
146
+ f"Agent server URL not found in sandbox {self._sandbox_id}"
147
+ )
148
+
149
+ logger.info(f"Sandbox ready at {agent_server_url}")
150
+
151
+ # Set host and api_key for RemoteWorkspace operations
152
+ self.host = agent_server_url.rstrip("/")
153
+ self.api_key = self._session_api_key
154
+
155
+ # Reset HTTP client with new host and API key
156
+ self.reset_client()
157
+
158
+ # Verify client is properly initialized
159
+ assert self.client is not None
160
+ assert self.client.base_url == self.host
161
+
162
+ @tenacity.retry(
163
+ stop=tenacity.stop_after_delay(300),
164
+ wait=tenacity.wait_exponential(multiplier=1, min=2, max=10),
165
+ retry=tenacity.retry_if_exception_type(RuntimeError),
166
+ reraise=True,
167
+ )
168
+ def _wait_until_sandbox_ready(self) -> None:
169
+ """Wait until the sandbox becomes RUNNING and responsive."""
170
+ logger.debug("Checking sandbox status...")
171
+
172
+ # GET /api/v1/sandboxes?id=<sandbox_id>
173
+ resp = self._send_api_request(
174
+ "GET",
175
+ f"{self.cloud_api_url}/api/v1/sandboxes",
176
+ params={"id": self._sandbox_id},
177
+ )
178
+ sandboxes = resp.json()
179
+
180
+ if not sandboxes or sandboxes[0] is None:
181
+ raise RuntimeError(f"Sandbox {self._sandbox_id} not found")
182
+
183
+ sandbox = sandboxes[0]
184
+ status = sandbox.get("status")
185
+ logger.info(f"Sandbox status: {status}")
186
+
187
+ if status == "RUNNING":
188
+ # Update session_api_key and exposed_urls from response
189
+ self._session_api_key = sandbox.get("session_api_key")
190
+ self._exposed_urls = sandbox.get("exposed_urls") or []
191
+
192
+ # Verify agent server is accessible
193
+ agent_server_url = self._get_agent_server_url()
194
+ if agent_server_url:
195
+ self._check_agent_server_health(agent_server_url)
196
+ return
197
+
198
+ elif status == "STARTING":
199
+ raise RuntimeError("Sandbox still starting")
200
+
201
+ elif status in ("ERROR", "MISSING"):
202
+ raise ValueError(f"Sandbox failed with status: {status}")
203
+
204
+ elif status == "PAUSED":
205
+ # Try to resume the sandbox
206
+ logger.info("Sandbox is paused, attempting to resume...")
207
+ self._resume_sandbox()
208
+ raise RuntimeError("Sandbox resuming, waiting for RUNNING status")
209
+
210
+ else:
211
+ logger.warning(f"Unknown sandbox status: {status}")
212
+ raise RuntimeError(f"Unknown sandbox status: {status}")
213
+
214
+ def _check_agent_server_health(self, agent_server_url: str) -> None:
215
+ """Check if the agent server is healthy."""
216
+ health_url = f"{agent_server_url.rstrip('/')}/health"
217
+ logger.debug(f"Checking agent server health at: {health_url}")
218
+ try:
219
+ with urlopen(health_url, timeout=5.0) as resp:
220
+ status = getattr(resp, "status", 200)
221
+ if 200 <= status < 300:
222
+ logger.debug("Agent server is healthy")
223
+ return
224
+ raise RuntimeError(f"Health check failed with status: {status}")
225
+ except Exception as e:
226
+ logger.warning(f"Health check failed: {e}")
227
+ raise RuntimeError(f"Agent server health check failed: {e}")
228
+
229
+ def _resume_sandbox(self) -> None:
230
+ """Resume a paused sandbox."""
231
+ if not self._sandbox_id:
232
+ return
233
+
234
+ logger.info(f"Resuming sandbox {self._sandbox_id}...")
235
+ self._send_api_request(
236
+ "POST",
237
+ f"{self.cloud_api_url}/api/v1/sandboxes/{self._sandbox_id}/resume",
238
+ timeout=self.init_timeout,
239
+ )
240
+
241
+ def _get_agent_server_url(self) -> str | None:
242
+ """Extract agent server URL from exposed_urls."""
243
+ if not self._exposed_urls:
244
+ return None
245
+
246
+ for url_info in self._exposed_urls:
247
+ if url_info.get("name") == AGENT_SERVER:
248
+ return url_info.get("url")
249
+
250
+ return None
251
+
252
+ def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
253
+ """Send an API request to the Cloud API with error handling."""
254
+ logger.debug(f"Sending {method} request to {url}")
255
+
256
+ # Ensure headers include API key
257
+ headers = kwargs.pop("headers", {})
258
+ headers.update(self._api_headers)
259
+
260
+ # Use a separate client for API requests (not the agent server client)
261
+ timeout = kwargs.pop("timeout", self.api_timeout)
262
+ with httpx.Client(timeout=timeout) as api_client:
263
+ response = api_client.request(method, url, headers=headers, **kwargs)
264
+
265
+ try:
266
+ response.raise_for_status()
267
+ except httpx.HTTPStatusError:
268
+ try:
269
+ error_detail = response.json()
270
+ logger.error(f"Cloud API request failed: {error_detail}")
271
+ except Exception:
272
+ logger.error(f"Cloud API request failed: {response.text}")
273
+ raise
274
+
275
+ return response
276
+
277
+ def cleanup(self) -> None:
278
+ """Clean up the sandbox by deleting it."""
279
+ if not self._sandbox_id:
280
+ return
281
+
282
+ try:
283
+ if self.keep_alive:
284
+ logger.info(f"Keeping sandbox {self._sandbox_id} alive")
285
+ return
286
+
287
+ logger.info(f"Deleting sandbox {self._sandbox_id}...")
288
+ self._send_api_request(
289
+ "DELETE",
290
+ f"{self.cloud_api_url}/api/v1/sandboxes",
291
+ params={"sandbox_id": self._sandbox_id},
292
+ timeout=30.0,
293
+ )
294
+ logger.info(f"Sandbox {self._sandbox_id} deleted")
295
+ except Exception as e:
296
+ logger.warning(f"Cleanup error: {e}")
297
+ finally:
298
+ self._sandbox_id = None
299
+ self._session_api_key = None
300
+ self._exposed_urls = None
301
+ try:
302
+ if self._client:
303
+ self._client.close()
304
+ except Exception:
305
+ pass
306
+
307
+ def __del__(self) -> None:
308
+ self.cleanup()
309
+
310
+ def __enter__(self) -> "OpenHandsCloudWorkspace":
311
+ return self
312
+
313
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
314
+ self.cleanup()
@@ -0,0 +1,20 @@
1
+ """Docker workspace implementation."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .workspace import DockerWorkspace
6
+
7
+
8
+ if TYPE_CHECKING:
9
+ from .dev_workspace import DockerDevWorkspace
10
+
11
+ __all__ = ["DockerWorkspace", "DockerDevWorkspace"]
12
+
13
+
14
+ def __getattr__(name: str):
15
+ """Lazy import DockerDevWorkspace to avoid build module imports."""
16
+ if name == "DockerDevWorkspace":
17
+ from .dev_workspace import DockerDevWorkspace
18
+
19
+ return DockerDevWorkspace
20
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,116 @@
1
+ """Docker development workspace with on-the-fly image building capability."""
2
+
3
+ from pydantic import Field, model_validator
4
+
5
+ from openhands.sdk.workspace import PlatformType, TargetType
6
+
7
+ from .workspace import DockerWorkspace
8
+
9
+
10
+ class DockerDevWorkspace(DockerWorkspace):
11
+ """Docker workspace with on-the-fly image building capability.
12
+
13
+ This workspace extends DockerWorkspace to support building Docker images
14
+ on-the-fly from a base image. This is useful for development and testing
15
+ scenarios where you need to customize the agent server environment.
16
+
17
+ Note: This class requires the OpenHands SDK workspace structure and should
18
+ only be used within the OpenHands development environment or when you have
19
+ the full SDK source code available.
20
+
21
+ For production use cases with pre-built images, use DockerWorkspace instead.
22
+
23
+ Example:
24
+ with DockerDevWorkspace(
25
+ base_image="python:3.12",
26
+ target="source"
27
+ ) as workspace:
28
+ result = workspace.execute_command("ls -la")
29
+ """
30
+
31
+ # Add base_image support
32
+ base_image: str | None = Field(
33
+ default=None,
34
+ description=(
35
+ "Base Docker image to build the agent server from. "
36
+ "Mutually exclusive with server_image."
37
+ ),
38
+ )
39
+
40
+ # Add build-specific options
41
+ target: TargetType = Field(
42
+ default="source", description="Build target for the Docker image."
43
+ )
44
+
45
+ @model_validator(mode="after")
46
+ def _validate_images(self):
47
+ """Ensure exactly one of base_image or server_image is provided."""
48
+ if (self.base_image is None) == (self.server_image is None):
49
+ raise ValueError(
50
+ "Exactly one of 'base_image' or 'server_image' must be set."
51
+ )
52
+ if self.base_image and "ghcr.io/openhands/agent-server" in self.base_image:
53
+ raise ValueError(
54
+ "base_image cannot be a pre-built agent-server image. "
55
+ "Use server_image=... instead."
56
+ )
57
+ return self
58
+
59
+ @staticmethod
60
+ def _build_image_from_base(
61
+ *, base_image: str, target: TargetType, platform: PlatformType
62
+ ) -> str:
63
+ """Build a Docker image from a base image.
64
+
65
+ Args:
66
+ base_image: The base Docker image to build from.
67
+ target: The build target (e.g., 'source', 'dev').
68
+ platform: The platform to build for (e.g., 'linux/amd64').
69
+
70
+ Returns:
71
+ The built Docker image tag.
72
+
73
+ Raises:
74
+ RuntimeError: If the base_image is a pre-built agent-server image
75
+ or if the build fails.
76
+ """
77
+ from openhands.agent_server.docker.build import BuildOptions, build
78
+
79
+ if "ghcr.io/openhands/agent-server" in base_image:
80
+ raise RuntimeError(
81
+ "base_image cannot be a pre-built agent-server image. "
82
+ "Use server_image=... instead."
83
+ )
84
+
85
+ build_opts = BuildOptions(
86
+ base_image=base_image,
87
+ target=target,
88
+ platforms=[platform],
89
+ push=False,
90
+ )
91
+ tags = build(opts=build_opts)
92
+ if not tags:
93
+ raise RuntimeError("Build failed, no image tags returned")
94
+ return tags[0]
95
+
96
+ def get_image(self) -> str:
97
+ """Build the image if base_image is provided, otherwise use server_image.
98
+
99
+ This overrides the parent method to add on-the-fly image building
100
+ capability.
101
+
102
+ Returns:
103
+ The Docker image tag to use.
104
+ """
105
+ if self.base_image:
106
+ # Build the image from base_image
107
+ return self._build_image_from_base(
108
+ base_image=self.base_image,
109
+ target=self.target,
110
+ platform=self.platform,
111
+ )
112
+ elif self.server_image:
113
+ # Use pre-built image
114
+ return self.server_image
115
+ else:
116
+ raise ValueError("Either base_image or server_image must be set")
@@ -0,0 +1,358 @@
1
+ """Docker-based remote workspace implementation."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ import time
8
+ import uuid
9
+ from typing import Any
10
+ from urllib.request import urlopen
11
+
12
+ from pydantic import Field, PrivateAttr, model_validator
13
+
14
+ from openhands.sdk.logger import get_logger
15
+ from openhands.sdk.utils.command import execute_command
16
+ from openhands.sdk.workspace import PlatformType, RemoteWorkspace
17
+
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ def check_port_available(port: int) -> bool:
23
+ """Check if a port is available for binding."""
24
+ import socket
25
+
26
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
27
+ try:
28
+ sock.bind(("0.0.0.0", port))
29
+ return True
30
+ except OSError:
31
+ time.sleep(0.1)
32
+ return False
33
+ finally:
34
+ sock.close()
35
+
36
+
37
+ def find_available_tcp_port(
38
+ min_port: int = 30000, max_port: int = 39999, max_attempts: int = 50
39
+ ) -> int:
40
+ """Find an available TCP port in a specified range."""
41
+ import random
42
+
43
+ rng = random.SystemRandom()
44
+ ports = list(range(min_port, max_port + 1))
45
+ rng.shuffle(ports)
46
+
47
+ for port in ports[:max_attempts]:
48
+ if check_port_available(port):
49
+ return port
50
+ return -1
51
+
52
+
53
+ class DockerWorkspace(RemoteWorkspace):
54
+ """Remote workspace that sets up and manages a Docker container.
55
+
56
+ This workspace creates a Docker container running a pre-built OpenHands agent
57
+ server image, waits for it to become healthy, and then provides remote workspace
58
+ operations through the container's HTTP API.
59
+
60
+ Note: This class only works with pre-built images. To build images on-the-fly
61
+ from a base image, use DockerDevWorkspace instead.
62
+
63
+ Example:
64
+ with DockerWorkspace(
65
+ server_image="ghcr.io/openhands/agent-server:latest"
66
+ ) as workspace:
67
+ result = workspace.execute_command("ls -la")
68
+ """
69
+
70
+ # Override parent fields with defaults
71
+ working_dir: str = Field(
72
+ default="/workspace",
73
+ description="Working directory inside the container.",
74
+ )
75
+ host: str = Field(
76
+ default="",
77
+ description=("Remote host URL (set automatically during container startup)."),
78
+ )
79
+
80
+ # Docker-specific configuration
81
+ server_image: str | None = Field(
82
+ default=None,
83
+ description="Pre-built agent server image to use.",
84
+ )
85
+ host_port: int | None = Field(
86
+ default=None,
87
+ description="Port to bind the container to. If None, finds available port.",
88
+ )
89
+ forward_env: list[str] = Field(
90
+ default_factory=lambda: ["DEBUG"],
91
+ description="Environment variables to forward to the container.",
92
+ )
93
+ mount_dir: str | None = Field(
94
+ default=None,
95
+ description="Optional host directory to mount into the container.",
96
+ )
97
+ detach_logs: bool = Field(
98
+ default=True, description="Whether to stream Docker logs in background."
99
+ )
100
+ platform: PlatformType = Field(
101
+ default="linux/amd64", description="Platform for the Docker image."
102
+ )
103
+ extra_ports: bool = Field(
104
+ default=False,
105
+ description="Whether to expose additional ports (VSCode, VNC).",
106
+ )
107
+ enable_gpu: bool = Field(
108
+ default=False,
109
+ description="Whether to enable GPU support with --gpus all.",
110
+ )
111
+ cleanup_image: bool = Field(
112
+ default=False,
113
+ description="Whether to delete the Docker image when cleaning up workspace.",
114
+ )
115
+
116
+ _container_id: str | None = PrivateAttr(default=None)
117
+ _image_name: str | None = PrivateAttr(default=None)
118
+ _logs_thread: threading.Thread | None = PrivateAttr(default=None)
119
+ _stop_logs: threading.Event = PrivateAttr(default_factory=threading.Event)
120
+
121
+ @model_validator(mode="after")
122
+ def _validate_server_image(self):
123
+ """Ensure server_image is set when using DockerWorkspace directly."""
124
+ if self.__class__ is DockerWorkspace and self.server_image is None:
125
+ raise ValueError("server_image must be provided")
126
+ return self
127
+
128
+ def model_post_init(self, context: Any) -> None:
129
+ """Set up the Docker container and initialize the remote workspace."""
130
+ # Subclasses should call get_image() to get the image to use
131
+ # This allows them to build or prepare the image before container startup
132
+ image = self.get_image()
133
+ self._start_container(image, context)
134
+
135
+ def get_image(self) -> str:
136
+ """Get the Docker image to use for the container.
137
+
138
+ Subclasses can override this to provide custom image resolution logic
139
+ (e.g., building images on-the-fly).
140
+
141
+ Returns:
142
+ The Docker image tag to use.
143
+ """
144
+ if self.server_image is None:
145
+ raise ValueError("server_image must be set")
146
+ return self.server_image
147
+
148
+ def _start_container(self, image: str, context: Any) -> None:
149
+ """Start the Docker container with the given image.
150
+
151
+ This method handles all container lifecycle: port allocation, Docker
152
+ validation, container creation, health checks, and RemoteWorkspace
153
+ initialization.
154
+
155
+ Args:
156
+ image: The Docker image tag to use.
157
+ context: The Pydantic context from model_post_init.
158
+ """
159
+ # Store the image name for cleanup
160
+ self._image_name = image
161
+
162
+ # Determine port
163
+ if self.host_port is None:
164
+ self.host_port = find_available_tcp_port()
165
+ else:
166
+ self.host_port = int(self.host_port)
167
+
168
+ if not check_port_available(self.host_port):
169
+ raise RuntimeError(f"Port {self.host_port} is not available")
170
+
171
+ if self.extra_ports:
172
+ if not check_port_available(self.host_port + 1):
173
+ raise RuntimeError(
174
+ f"Port {self.host_port + 1} is not available for VSCode"
175
+ )
176
+ if not check_port_available(self.host_port + 2):
177
+ raise RuntimeError(
178
+ f"Port {self.host_port + 2} is not available for VNC"
179
+ )
180
+
181
+ # Ensure docker is available
182
+ docker_ver = execute_command(["docker", "version"]).returncode
183
+ if docker_ver != 0:
184
+ raise RuntimeError(
185
+ "Docker is not available. Please install and start "
186
+ "Docker Desktop/daemon."
187
+ )
188
+
189
+ # Prepare Docker run flags
190
+ flags: list[str] = []
191
+ for key in self.forward_env:
192
+ if key in os.environ:
193
+ flags += ["-e", f"{key}={os.environ[key]}"]
194
+
195
+ if self.mount_dir:
196
+ mount_path = "/workspace"
197
+ flags += ["-v", f"{self.mount_dir}:{mount_path}"]
198
+ logger.info(
199
+ "Mounting host dir %s to container path %s",
200
+ self.mount_dir,
201
+ mount_path,
202
+ )
203
+
204
+ ports = ["-p", f"{self.host_port}:8000"]
205
+ if self.extra_ports:
206
+ ports += [
207
+ "-p",
208
+ f"{self.host_port + 1}:8001", # VSCode
209
+ "-p",
210
+ f"{self.host_port + 2}:8002", # Desktop VNC
211
+ ]
212
+ flags += ports
213
+
214
+ # Add GPU support if enabled
215
+ if self.enable_gpu:
216
+ flags += ["--gpus", "all"]
217
+
218
+ # Run container
219
+ run_cmd = [
220
+ "docker",
221
+ "run",
222
+ "-d",
223
+ "--platform",
224
+ self.platform,
225
+ "--rm",
226
+ "--name",
227
+ f"agent-server-{uuid.uuid4()}",
228
+ *flags,
229
+ image,
230
+ "--host",
231
+ "0.0.0.0",
232
+ "--port",
233
+ "8000",
234
+ ]
235
+ proc = execute_command(run_cmd)
236
+ if proc.returncode != 0:
237
+ raise RuntimeError(f"Failed to run docker container: {proc.stderr}")
238
+
239
+ self._container_id = proc.stdout.strip()
240
+ logger.info("Started container: %s", self._container_id)
241
+
242
+ # Optionally stream logs in background
243
+ if self.detach_logs:
244
+ self._logs_thread = threading.Thread(
245
+ target=self._stream_docker_logs, daemon=True
246
+ )
247
+ self._logs_thread.start()
248
+
249
+ # Set host for RemoteWorkspace to use
250
+ # The container exposes port 8000, mapped to self.host_port
251
+ # Override parent's host initialization
252
+ object.__setattr__(self, "host", f"http://localhost:{self.host_port}")
253
+ object.__setattr__(self, "api_key", None)
254
+
255
+ # Wait for container to be healthy
256
+ self._wait_for_health()
257
+ logger.info("Docker workspace is ready at %s", self.host)
258
+
259
+ # Now initialize the parent RemoteWorkspace with the container URL
260
+ super().model_post_init(context)
261
+
262
+ def _stream_docker_logs(self) -> None:
263
+ """Stream Docker logs to stdout in the background."""
264
+ if not self._container_id:
265
+ return
266
+ try:
267
+ p = subprocess.Popen(
268
+ ["docker", "logs", "-f", self._container_id],
269
+ stdout=subprocess.PIPE,
270
+ stderr=subprocess.STDOUT,
271
+ text=True,
272
+ )
273
+ if p.stdout is None:
274
+ return
275
+ for line in iter(p.stdout.readline, ""):
276
+ if self._stop_logs.is_set():
277
+ break
278
+ if line:
279
+ sys.stdout.write(f"[DOCKER] {line}")
280
+ sys.stdout.flush()
281
+ except Exception as e:
282
+ sys.stderr.write(f"Error streaming docker logs: {e}\n")
283
+ finally:
284
+ try:
285
+ self._stop_logs.set()
286
+ except Exception:
287
+ pass
288
+
289
+ def _wait_for_health(self, timeout: float = 120.0) -> None:
290
+ """Wait for the Docker container to become healthy."""
291
+ start = time.time()
292
+ health_url = f"http://127.0.0.1:{self.host_port}/health"
293
+
294
+ while time.time() - start < timeout:
295
+ try:
296
+ with urlopen(health_url, timeout=1.0) as resp:
297
+ if 200 <= getattr(resp, "status", 200) < 300:
298
+ return
299
+ except Exception:
300
+ pass
301
+
302
+ # Check if container is still running
303
+ if self._container_id:
304
+ ps = execute_command(
305
+ [
306
+ "docker",
307
+ "inspect",
308
+ "-f",
309
+ "{{.State.Running}}",
310
+ self._container_id,
311
+ ]
312
+ )
313
+ if ps.stdout.strip() != "true":
314
+ logs = execute_command(["docker", "logs", self._container_id])
315
+ msg = (
316
+ "Container stopped unexpectedly. Logs:\n"
317
+ f"{logs.stdout}\n{logs.stderr}"
318
+ )
319
+ raise RuntimeError(msg)
320
+ time.sleep(1)
321
+ raise RuntimeError("Container failed to become healthy in time")
322
+
323
+ def __enter__(self) -> "DockerWorkspace":
324
+ """Context manager entry - returns the workspace itself."""
325
+ return self
326
+
327
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
328
+ """Context manager exit - cleans up the Docker container."""
329
+ self.cleanup()
330
+
331
+ def __del__(self) -> None:
332
+ """Clean up the Docker container when the workspace is destroyed."""
333
+ self.cleanup()
334
+
335
+ def cleanup(self) -> None:
336
+ """Stop and remove the Docker container."""
337
+ if self._container_id:
338
+ # Stop logs streaming
339
+ self._stop_logs.set()
340
+ if self._logs_thread and self._logs_thread.is_alive():
341
+ self._logs_thread.join(timeout=2)
342
+
343
+ # Stop and remove the container
344
+ logger.info("Stopping container: %s", self._container_id)
345
+ execute_command(["docker", "stop", self._container_id])
346
+ self._container_id = None
347
+
348
+ # Optionally delete the Docker image
349
+ if self.cleanup_image and self._image_name:
350
+ logger.info("Deleting Docker image: %s", self._image_name)
351
+ result = execute_command(["docker", "rmi", "-f", self._image_name])
352
+ if result.returncode == 0:
353
+ logger.info("Successfully deleted image: %s", self._image_name)
354
+ else:
355
+ logger.warning(
356
+ "Failed to delete image %s: %s", self._image_name, result.stderr
357
+ )
358
+ self._image_name = None
File without changes
@@ -0,0 +1,6 @@
1
+ """Runtime API workspace implementation."""
2
+
3
+ from .workspace import APIRemoteWorkspace
4
+
5
+
6
+ __all__ = ["APIRemoteWorkspace"]
@@ -0,0 +1,356 @@
1
+ """API-based remote workspace implementation using runtime API."""
2
+
3
+ import uuid
4
+ from typing import Any, Literal
5
+ from urllib.request import urlopen
6
+
7
+ import httpx
8
+ import tenacity
9
+ from pydantic import Field, PrivateAttr
10
+
11
+ from openhands.sdk.logger import get_logger
12
+ from openhands.sdk.workspace.remote.base import RemoteWorkspace
13
+
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class APIRemoteWorkspace(RemoteWorkspace):
19
+ """Remote workspace using OpenHands runtime API.
20
+
21
+ Runtime API: https://runtime.all-hands.dev/
22
+
23
+ Example:
24
+ workspace = APIRemoteWorkspace(
25
+ runtime_api_url="https://runtime.eval.all-hands.dev",
26
+ runtime_api_key="your-api-key",
27
+ server_image="ghcr.io/openhands/agent-server:lastest-python",
28
+ )
29
+ """ # noqa: E501
30
+
31
+ # Parent fields
32
+ working_dir: str = Field(
33
+ default="/workspace",
34
+ description="Working directory inside the remote workspace",
35
+ )
36
+ host: str = Field(
37
+ default="undefined",
38
+ description="The remote host URL for the workspace."
39
+ " It will be set to the runtime URL after connecting.",
40
+ )
41
+
42
+ # Runtime API fields
43
+ runtime_api_url: str = Field(description="Base URL of the runtime API")
44
+ runtime_api_key: str = Field(description="API key for authentication")
45
+ server_image: str = Field(
46
+ description="Container image for the agent server. "
47
+ "It must be a public image or in a registry accessible by runtime API."
48
+ )
49
+ image_pull_policy: Literal["Always", "IfNotPresent", "Never"] = Field(
50
+ default="IfNotPresent",
51
+ description="Image pull policy for the API",
52
+ )
53
+ session_id: str | None = Field(
54
+ default_factory=lambda: f"agent-server-{uuid.uuid4()}",
55
+ description="Session ID (auto-generated if None)",
56
+ )
57
+ resource_factor: int = Field(
58
+ default=1, description="Resource scaling (1, 2, 4, or 8)"
59
+ )
60
+ runtime_class: str | None = Field(
61
+ default="sysbox-runc", description="Runtime class (e.g., 'sysbox')"
62
+ )
63
+ init_timeout: float = Field(
64
+ default=300.0, description="Runtime init timeout (seconds)"
65
+ )
66
+ api_timeout: float = Field(
67
+ default=60.0, description="API request timeout (seconds)"
68
+ )
69
+ keep_alive: bool = Field(default=False, description="Keep runtime alive on cleanup")
70
+ pause_on_close: bool = Field(
71
+ default=False, description="Pause instead of stop on cleanup"
72
+ )
73
+ target_type: Literal["binary", "source"] = Field(
74
+ default="binary",
75
+ description="Type of agent server target (binary or source)",
76
+ )
77
+
78
+ _runtime_id: str | None = PrivateAttr(default=None)
79
+ _runtime_url: str | None = PrivateAttr(default=None)
80
+ _session_api_key: str | None = PrivateAttr(default=None)
81
+
82
+ @property
83
+ def client(self) -> httpx.Client:
84
+ """Override client property to use api_timeout for HTTP requests."""
85
+ client = self._client
86
+ if client is None:
87
+ # Use api_timeout for the read timeout to allow longer operations
88
+ timeout = httpx.Timeout(
89
+ connect=10.0,
90
+ read=self.api_timeout,
91
+ write=10.0,
92
+ pool=10.0,
93
+ )
94
+ client = httpx.Client(
95
+ base_url=self.host, timeout=timeout, headers=self._headers
96
+ )
97
+ self._client = client
98
+ return client
99
+
100
+ @property
101
+ def _api_headers(self):
102
+ """Headers for runtime API requests."
103
+
104
+ This is used to manage new container runtimes via Runtime API.
105
+
106
+ For actual interaction with the remote agent server, the
107
+ `client` property is used, which includes the session API key
108
+ defined by ._headers property.
109
+ """
110
+ headers = {}
111
+ if self.runtime_api_key:
112
+ headers["X-API-Key"] = self.runtime_api_key
113
+ return headers
114
+
115
+ def model_post_init(self, context: Any) -> None:
116
+ """Set up the remote runtime and initialize the workspace."""
117
+ if self.resource_factor not in [1, 2, 4, 8]:
118
+ raise ValueError(
119
+ f"resource_factor must be 1, 2, 4, or 8, got {self.resource_factor}"
120
+ )
121
+
122
+ self.runtime_api_url = self.runtime_api_url.rstrip("/")
123
+
124
+ try:
125
+ self._start_or_attach_to_runtime()
126
+ super().model_post_init(context)
127
+ except Exception:
128
+ self.cleanup()
129
+ raise
130
+
131
+ def _start_or_attach_to_runtime(self) -> None:
132
+ """Start or attach to an existing runtime."""
133
+ if not self._check_existing_runtime():
134
+ self._start_runtime()
135
+
136
+ assert self._runtime_id and self._runtime_url, "Runtime ID/URL not set"
137
+ self._wait_until_runtime_alive()
138
+ logger.info(f"Runtime ready at {self._runtime_url}")
139
+ self.host = self._runtime_url.rstrip("/")
140
+ self.api_key = self._session_api_key
141
+ # Reset HTTP client with new host and API key
142
+ self.reset_client()
143
+ # Verify client is properly initialized
144
+ assert self.client is not None
145
+ assert self.client.base_url == self.host
146
+
147
+ def _check_existing_runtime(self) -> bool:
148
+ """Check if there's an existing runtime for this session."""
149
+ try:
150
+ resp = self._send_api_request(
151
+ "GET",
152
+ f"{self.runtime_api_url}/sessions/{self.session_id}",
153
+ headers=self._api_headers,
154
+ )
155
+ data = resp.json()
156
+ status = data.get("status")
157
+ logger.info(f"Runtime status: {status}")
158
+
159
+ if status in ("running", "paused"):
160
+ self._parse_runtime_response(resp)
161
+ if status == "paused":
162
+ try:
163
+ self._resume_runtime()
164
+ except Exception as e:
165
+ logger.error(f"Resume failed: {e}")
166
+ return False
167
+ return True
168
+ return False
169
+ except httpx.HTTPStatusError as e:
170
+ if e.response.status_code == 404:
171
+ return False
172
+ raise
173
+
174
+ def _start_runtime(self) -> None:
175
+ """Start a new runtime."""
176
+ if self.target_type == "binary":
177
+ executable = "/usr/local/bin/openhands-agent-server"
178
+ else:
179
+ executable = "/agent-server/.venv/bin/python -m openhands.agent_server"
180
+ # For binary target, use the standalone binary
181
+ payload: dict[str, Any] = {
182
+ "image": self.server_image,
183
+ "command": f"{executable} --port 60000",
184
+ "working_dir": "/", # Match Dockerfile WORKDIR
185
+ "environment": {},
186
+ "session_id": self.session_id,
187
+ "run_as_user": 10001,
188
+ "fs_group": 10001,
189
+ "image_pull_policy": self.image_pull_policy,
190
+ }
191
+
192
+ if self.runtime_class:
193
+ payload["runtime_class"] = self.runtime_class
194
+ if self.resource_factor != 1:
195
+ payload["resource_factor"] = self.resource_factor
196
+
197
+ logger.info(f"Starting runtime with {self.server_image}")
198
+ logger.info(f"Payload: {payload}")
199
+ resp = self._send_api_request(
200
+ "POST",
201
+ f"{self.runtime_api_url}/start",
202
+ json=payload,
203
+ timeout=self.init_timeout,
204
+ headers=self._api_headers,
205
+ )
206
+ self._parse_runtime_response(resp)
207
+ logger.info(f"Runtime {self._runtime_id} at {self._runtime_url}")
208
+
209
+ def _resume_runtime(self) -> None:
210
+ """Resume a paused runtime."""
211
+ resp = self._send_api_request(
212
+ "POST",
213
+ f"{self.runtime_api_url}/resume",
214
+ json={"runtime_id": self._runtime_id},
215
+ timeout=self.init_timeout,
216
+ headers=self._api_headers,
217
+ )
218
+ self._parse_runtime_response(resp)
219
+
220
+ def _parse_runtime_response(self, response: httpx.Response) -> None:
221
+ """Parse the runtime response and extract connection info."""
222
+ data = response.json()
223
+ self._runtime_id = data.get("runtime_id") or data.get("id")
224
+ self._runtime_url = data.get("url")
225
+ self._session_api_key = data.get("session_api_key")
226
+ if not self._runtime_id or not self._runtime_url:
227
+ raise ValueError(f"Invalid runtime response: {data}")
228
+
229
+ @tenacity.retry(
230
+ stop=tenacity.stop_after_delay(300),
231
+ wait=tenacity.wait_exponential(multiplier=1, min=2, max=10),
232
+ retry=tenacity.retry_if_exception_type(RuntimeError),
233
+ reraise=True,
234
+ )
235
+ def _wait_until_runtime_alive(self) -> None:
236
+ """Wait until the runtime becomes alive and responsive."""
237
+ logger.info("Waiting for runtime to become alive...")
238
+
239
+ resp = self._send_api_request(
240
+ "GET",
241
+ f"{self.runtime_api_url}/sessions/{self.session_id}",
242
+ headers=self._api_headers,
243
+ )
244
+ data = resp.json()
245
+ pod_status = data.get("pod_status", "").lower()
246
+ logger.info(f"Pod status: {pod_status}")
247
+
248
+ # Log additional details for debugging
249
+ if pod_status == "pending":
250
+ container_statuses = data.get("container_statuses", [])
251
+ events = data.get("events", [])
252
+ if container_statuses:
253
+ logger.warning(f"Container statuses: {container_statuses}")
254
+ if events:
255
+ logger.warning(f"Pod events: {events}")
256
+ logger.debug(f"Full response: {data}")
257
+
258
+ restart_count = data.get("restart_count", 0)
259
+ if restart_count > 0:
260
+ restart_reasons = data.get("restart_reasons", [])
261
+ logger.warning(f"Pod restarts: {restart_count}, reasons: {restart_reasons}")
262
+
263
+ # Handle different pod states
264
+ if pod_status == "ready":
265
+ # Pod is ready, check health endpoint
266
+ health_url = f"{self._runtime_url}/health"
267
+ logger.info(f"Checking health at: {health_url}")
268
+ try:
269
+ with urlopen(health_url, timeout=5.0) as resp:
270
+ status = getattr(resp, "status", 200)
271
+ logger.info(f"Health check response: {status}")
272
+ if 200 <= status < 300:
273
+ logger.info("Runtime is alive!")
274
+ return
275
+ raise RuntimeError(f"Health check failed with status: {status}")
276
+ except Exception as e:
277
+ logger.warning(f"Health check failed: {e}")
278
+ raise RuntimeError(f"Runtime /health failed: {e}")
279
+ elif pod_status in ("not found", "pending", "running"):
280
+ # Transient states - continue retrying
281
+ logger.debug(f"Runtime not yet ready. Status: {pod_status}")
282
+ raise RuntimeError(f"Runtime not yet ready (status: {pod_status})")
283
+ elif pod_status in ("failed", "unknown", "crashloopbackoff"):
284
+ # Terminal failure states
285
+ pod_logs = data.get("pod_logs", "")
286
+ error_msg = f"Runtime failed (status: {pod_status})"
287
+ if pod_logs:
288
+ logger.error(f"Pod logs: {pod_logs}")
289
+ error_msg += f"\nPod logs: {pod_logs}"
290
+ if pod_status == "crashloopbackoff":
291
+ error_msg = (
292
+ "Runtime crashed and is restarting (possibly OOM). Try again."
293
+ )
294
+ raise ValueError(error_msg)
295
+ else:
296
+ # Unknown status - log and retry
297
+ logger.warning(f"Unknown pod status: {pod_status}, full response: {data}")
298
+ raise RuntimeError(f"Unknown pod status: {pod_status}")
299
+
300
+ def _send_api_request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
301
+ """Send an API request with error handling."""
302
+ logger.debug(f"Sending {method} request to {url}")
303
+ logger.debug(f"Request kwargs: {kwargs.keys()}")
304
+
305
+ response = self.client.request(method, url, **kwargs)
306
+ try:
307
+ response.raise_for_status()
308
+ except httpx.HTTPStatusError:
309
+ # Log only header keys, not values (to avoid exposing API keys)
310
+ header_keys = list(response.request.headers.keys())
311
+ logger.debug(f"Request header keys: {header_keys}")
312
+ try:
313
+ error_detail = response.json()
314
+ logger.info(f"API request failed: {error_detail}")
315
+ except Exception:
316
+ logger.info(f"API request failed: {response.text}")
317
+ raise
318
+ return response
319
+
320
+ def cleanup(self) -> None:
321
+ """Clean up the remote runtime."""
322
+ if not self._runtime_id:
323
+ return
324
+
325
+ try:
326
+ if self.keep_alive:
327
+ return
328
+
329
+ action = "pause" if self.pause_on_close else "stop"
330
+ logger.info(f"{action.capitalize()}ing runtime {self._runtime_id}")
331
+ self._send_api_request(
332
+ "POST",
333
+ f"{self.runtime_api_url}/{action}",
334
+ json={"runtime_id": self._runtime_id},
335
+ timeout=30.0,
336
+ headers=self._api_headers,
337
+ )
338
+ except Exception as e:
339
+ logger.warning(f"Cleanup error: {e}")
340
+ finally:
341
+ self._runtime_id = None
342
+ self._runtime_url = None
343
+ self._session_api_key = None
344
+ try:
345
+ self.client.close()
346
+ except Exception:
347
+ pass
348
+
349
+ def __del__(self) -> None:
350
+ self.cleanup()
351
+
352
+ def __enter__(self) -> "APIRemoteWorkspace":
353
+ return self
354
+
355
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
356
+ self.cleanup()
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhands-workspace
3
+ Version: 1.7.1
4
+ Summary: OpenHands Workspace - Docker and container-based workspace implementations
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: openhands-sdk
7
+ Requires-Dist: pydantic>=2.11.7
@@ -0,0 +1,13 @@
1
+ openhands/workspace/__init__.py,sha256=yr1_q7SLXTOt7FyAilUWLdH5KY6N1qjMytQZMFoy5W0,819
2
+ openhands/workspace/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ openhands/workspace/cloud/__init__.py,sha256=Ck8mDOf1YF4M1vn-vNkqxqGaEgB1qsQ7lLT9lTaS6gM,136
4
+ openhands/workspace/cloud/workspace.py,sha256=zB1gfKMo26J_I14_7eke1TVIDLr6UlymOtrxdhOtOyU,10782
5
+ openhands/workspace/docker/__init__.py,sha256=vhMjEqbG5NmwL2UINxtAZj_y5lKlaLlqUNz2f3owJVQ,539
6
+ openhands/workspace/docker/dev_workspace.py,sha256=5HZFXhtP9UZRST76E705TmYtVJcYbJIyq38GQFxiefE,3993
7
+ openhands/workspace/docker/workspace.py,sha256=FpCh2ElSYjDyp0VmA3JvmpK4O6_5gcMoUJdrmuel0CI,12400
8
+ openhands/workspace/remote_api/__init__.py,sha256=RxDLFcveTGG-uLm-PInfxFnYwYPdehRmLf1r0_KTaP4,122
9
+ openhands/workspace/remote_api/workspace.py,sha256=F8utWHzDyCeaXVjscuC3A-GF0O1LRrK1j82OJE7wFZI,13574
10
+ openhands_workspace-1.7.1.dist-info/METADATA,sha256=8S_XdVax1Ng9LMWR3P52Rf6S5BWps0AvdxHMsWrKmoQ,232
11
+ openhands_workspace-1.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ openhands_workspace-1.7.1.dist-info/top_level.txt,sha256=jHgVu9I0Blam8BXFgedoGKfglPF8XvW1TsJFIjcgP4E,10
13
+ openhands_workspace-1.7.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ openhands