openhands-workspace 1.6.0__tar.gz

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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: openhands-workspace
3
+ Version: 1.6.0
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,29 @@
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 .docker import DockerWorkspace
8
+ from .remote_api import APIRemoteWorkspace
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from .docker import DockerDevWorkspace
13
+
14
+ __all__ = [
15
+ "APIRemoteWorkspace",
16
+ "DockerDevWorkspace",
17
+ "DockerWorkspace",
18
+ "PlatformType",
19
+ "TargetType",
20
+ ]
21
+
22
+
23
+ def __getattr__(name: str):
24
+ """Lazy import DockerDevWorkspace to avoid build module imports."""
25
+ if name == "DockerDevWorkspace":
26
+ from .docker import DockerDevWorkspace
27
+
28
+ return DockerDevWorkspace
29
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -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,338 @@
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
+
112
+ _container_id: str | None = PrivateAttr(default=None)
113
+ _logs_thread: threading.Thread | None = PrivateAttr(default=None)
114
+ _stop_logs: threading.Event = PrivateAttr(default_factory=threading.Event)
115
+
116
+ @model_validator(mode="after")
117
+ def _validate_server_image(self):
118
+ """Ensure server_image is set when using DockerWorkspace directly."""
119
+ if self.__class__ is DockerWorkspace and self.server_image is None:
120
+ raise ValueError("server_image must be provided")
121
+ return self
122
+
123
+ def model_post_init(self, context: Any) -> None:
124
+ """Set up the Docker container and initialize the remote workspace."""
125
+ # Subclasses should call get_image() to get the image to use
126
+ # This allows them to build or prepare the image before container startup
127
+ image = self.get_image()
128
+ self._start_container(image, context)
129
+
130
+ def get_image(self) -> str:
131
+ """Get the Docker image to use for the container.
132
+
133
+ Subclasses can override this to provide custom image resolution logic
134
+ (e.g., building images on-the-fly).
135
+
136
+ Returns:
137
+ The Docker image tag to use.
138
+ """
139
+ if self.server_image is None:
140
+ raise ValueError("server_image must be set")
141
+ return self.server_image
142
+
143
+ def _start_container(self, image: str, context: Any) -> None:
144
+ """Start the Docker container with the given image.
145
+
146
+ This method handles all container lifecycle: port allocation, Docker
147
+ validation, container creation, health checks, and RemoteWorkspace
148
+ initialization.
149
+
150
+ Args:
151
+ image: The Docker image tag to use.
152
+ context: The Pydantic context from model_post_init.
153
+ """
154
+ # Determine port
155
+ if self.host_port is None:
156
+ self.host_port = find_available_tcp_port()
157
+ else:
158
+ self.host_port = int(self.host_port)
159
+
160
+ if not check_port_available(self.host_port):
161
+ raise RuntimeError(f"Port {self.host_port} is not available")
162
+
163
+ if self.extra_ports:
164
+ if not check_port_available(self.host_port + 1):
165
+ raise RuntimeError(
166
+ f"Port {self.host_port + 1} is not available for VSCode"
167
+ )
168
+ if not check_port_available(self.host_port + 2):
169
+ raise RuntimeError(
170
+ f"Port {self.host_port + 2} is not available for VNC"
171
+ )
172
+
173
+ # Ensure docker is available
174
+ docker_ver = execute_command(["docker", "version"]).returncode
175
+ if docker_ver != 0:
176
+ raise RuntimeError(
177
+ "Docker is not available. Please install and start "
178
+ "Docker Desktop/daemon."
179
+ )
180
+
181
+ # Prepare Docker run flags
182
+ flags: list[str] = []
183
+ for key in self.forward_env:
184
+ if key in os.environ:
185
+ flags += ["-e", f"{key}={os.environ[key]}"]
186
+
187
+ if self.mount_dir:
188
+ mount_path = "/workspace"
189
+ flags += ["-v", f"{self.mount_dir}:{mount_path}"]
190
+ logger.info(
191
+ "Mounting host dir %s to container path %s",
192
+ self.mount_dir,
193
+ mount_path,
194
+ )
195
+
196
+ ports = ["-p", f"{self.host_port}:8000"]
197
+ if self.extra_ports:
198
+ ports += [
199
+ "-p",
200
+ f"{self.host_port + 1}:8001", # VSCode
201
+ "-p",
202
+ f"{self.host_port + 2}:8002", # Desktop VNC
203
+ ]
204
+ flags += ports
205
+
206
+ # Add GPU support if enabled
207
+ if self.enable_gpu:
208
+ flags += ["--gpus", "all"]
209
+
210
+ # Run container
211
+ run_cmd = [
212
+ "docker",
213
+ "run",
214
+ "-d",
215
+ "--platform",
216
+ self.platform,
217
+ "--rm",
218
+ "--name",
219
+ f"agent-server-{uuid.uuid4()}",
220
+ *flags,
221
+ image,
222
+ "--host",
223
+ "0.0.0.0",
224
+ "--port",
225
+ "8000",
226
+ ]
227
+ proc = execute_command(run_cmd)
228
+ if proc.returncode != 0:
229
+ raise RuntimeError(f"Failed to run docker container: {proc.stderr}")
230
+
231
+ self._container_id = proc.stdout.strip()
232
+ logger.info("Started container: %s", self._container_id)
233
+
234
+ # Optionally stream logs in background
235
+ if self.detach_logs:
236
+ self._logs_thread = threading.Thread(
237
+ target=self._stream_docker_logs, daemon=True
238
+ )
239
+ self._logs_thread.start()
240
+
241
+ # Set host for RemoteWorkspace to use
242
+ # The container exposes port 8000, mapped to self.host_port
243
+ # Override parent's host initialization
244
+ object.__setattr__(self, "host", f"http://localhost:{self.host_port}")
245
+ object.__setattr__(self, "api_key", None)
246
+
247
+ # Wait for container to be healthy
248
+ self._wait_for_health()
249
+ logger.info("Docker workspace is ready at %s", self.host)
250
+
251
+ # Now initialize the parent RemoteWorkspace with the container URL
252
+ super().model_post_init(context)
253
+
254
+ def _stream_docker_logs(self) -> None:
255
+ """Stream Docker logs to stdout in the background."""
256
+ if not self._container_id:
257
+ return
258
+ try:
259
+ p = subprocess.Popen(
260
+ ["docker", "logs", "-f", self._container_id],
261
+ stdout=subprocess.PIPE,
262
+ stderr=subprocess.STDOUT,
263
+ text=True,
264
+ )
265
+ if p.stdout is None:
266
+ return
267
+ for line in iter(p.stdout.readline, ""):
268
+ if self._stop_logs.is_set():
269
+ break
270
+ if line:
271
+ sys.stdout.write(f"[DOCKER] {line}")
272
+ sys.stdout.flush()
273
+ except Exception as e:
274
+ sys.stderr.write(f"Error streaming docker logs: {e}\n")
275
+ finally:
276
+ try:
277
+ self._stop_logs.set()
278
+ except Exception:
279
+ pass
280
+
281
+ def _wait_for_health(self, timeout: float = 120.0) -> None:
282
+ """Wait for the Docker container to become healthy."""
283
+ start = time.time()
284
+ health_url = f"http://127.0.0.1:{self.host_port}/health"
285
+
286
+ while time.time() - start < timeout:
287
+ try:
288
+ with urlopen(health_url, timeout=1.0) as resp:
289
+ if 200 <= getattr(resp, "status", 200) < 300:
290
+ return
291
+ except Exception:
292
+ pass
293
+
294
+ # Check if container is still running
295
+ if self._container_id:
296
+ ps = execute_command(
297
+ [
298
+ "docker",
299
+ "inspect",
300
+ "-f",
301
+ "{{.State.Running}}",
302
+ self._container_id,
303
+ ]
304
+ )
305
+ if ps.stdout.strip() != "true":
306
+ logs = execute_command(["docker", "logs", self._container_id])
307
+ msg = (
308
+ "Container stopped unexpectedly. Logs:\n"
309
+ f"{logs.stdout}\n{logs.stderr}"
310
+ )
311
+ raise RuntimeError(msg)
312
+ time.sleep(1)
313
+ raise RuntimeError("Container failed to become healthy in time")
314
+
315
+ def __enter__(self) -> "DockerWorkspace":
316
+ """Context manager entry - returns the workspace itself."""
317
+ return self
318
+
319
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
320
+ """Context manager exit - cleans up the Docker container."""
321
+ self.cleanup()
322
+
323
+ def __del__(self) -> None:
324
+ """Clean up the Docker container when the workspace is destroyed."""
325
+ self.cleanup()
326
+
327
+ def cleanup(self) -> None:
328
+ """Stop and remove the Docker container."""
329
+ if self._container_id:
330
+ # Stop logs streaming
331
+ self._stop_logs.set()
332
+ if self._logs_thread and self._logs_thread.is_alive():
333
+ self._logs_thread.join(timeout=2)
334
+
335
+ # Stop and remove the container
336
+ logger.info("Stopping container: %s", self._container_id)
337
+ execute_command(["docker", "stop", self._container_id])
338
+ self._container_id = 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.6.0
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,20 @@
1
+ pyproject.toml
2
+ ./openhands/workspace/__init__.py
3
+ ./openhands/workspace/py.typed
4
+ ./openhands/workspace/docker/__init__.py
5
+ ./openhands/workspace/docker/dev_workspace.py
6
+ ./openhands/workspace/docker/workspace.py
7
+ ./openhands/workspace/remote_api/__init__.py
8
+ ./openhands/workspace/remote_api/workspace.py
9
+ openhands/workspace/__init__.py
10
+ openhands/workspace/py.typed
11
+ openhands/workspace/docker/__init__.py
12
+ openhands/workspace/docker/dev_workspace.py
13
+ openhands/workspace/docker/workspace.py
14
+ openhands/workspace/remote_api/__init__.py
15
+ openhands/workspace/remote_api/workspace.py
16
+ openhands_workspace.egg-info/PKG-INFO
17
+ openhands_workspace.egg-info/SOURCES.txt
18
+ openhands_workspace.egg-info/dependency_links.txt
19
+ openhands_workspace.egg-info/requires.txt
20
+ openhands_workspace.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ openhands-sdk
2
+ pydantic>=2.11.7
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "openhands-workspace"
3
+ version = "1.6.0"
4
+ description = "OpenHands Workspace - Docker and container-based workspace implementations"
5
+
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "openhands-sdk",
9
+ "pydantic>=2.11.7",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["setuptools>=61.0", "wheel"]
14
+ build-backend = "setuptools.build_meta"
15
+
16
+ [tool.setuptools.package-dir]
17
+ "" = "."
18
+
19
+ [tool.setuptools.packages.find]
20
+ include = ["openhands.workspace*"]
21
+ namespaces = true
22
+
23
+ [tool.setuptools.package-data]
24
+ "*" = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+