openhands-workspace 1.0.0a1__py3-none-any.whl → 1.11.0__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,33 @@
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 .apptainer import ApptainerWorkspace
8
+ from .cloud import OpenHandsCloudWorkspace
9
+ from .docker import DockerWorkspace
10
+ from .remote_api import APIRemoteWorkspace
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from .docker import DockerDevWorkspace
15
+
16
+ __all__ = [
17
+ "APIRemoteWorkspace",
18
+ "ApptainerWorkspace",
19
+ "DockerDevWorkspace",
20
+ "DockerWorkspace",
21
+ "OpenHandsCloudWorkspace",
22
+ "PlatformType",
23
+ "TargetType",
24
+ ]
25
+
26
+
27
+ def __getattr__(name: str):
28
+ """Lazy import DockerDevWorkspace to avoid build module imports."""
29
+ if name == "DockerDevWorkspace":
30
+ from .docker import DockerDevWorkspace
31
+
32
+ return DockerDevWorkspace
33
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,6 @@
1
+ """Apptainer workspace implementation."""
2
+
3
+ from .workspace import ApptainerWorkspace
4
+
5
+
6
+ __all__ = ["ApptainerWorkspace"]
@@ -0,0 +1,377 @@
1
+ """Apptainer-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 pathlib import Path
10
+ from typing import Any
11
+ from urllib.request import urlopen
12
+
13
+ from pydantic import Field, PrivateAttr
14
+
15
+ from openhands.sdk.logger import get_logger
16
+ from openhands.sdk.utils.command import execute_command
17
+ from openhands.sdk.workspace import PlatformType, RemoteWorkspace
18
+ from openhands.workspace.docker.workspace import (
19
+ check_port_available,
20
+ find_available_tcp_port,
21
+ )
22
+
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class ApptainerWorkspace(RemoteWorkspace):
28
+ """Remote workspace that sets up and manages an Apptainer container.
29
+
30
+ This workspace creates an Apptainer container running a pre-built OpenHands
31
+ agent server image, waits for it to become healthy, and then provides remote
32
+ workspace operations through the container's HTTP API.
33
+
34
+ Apptainer (formerly Singularity) is a container runtime that doesn't require
35
+ root access, making it ideal for HPC and shared computing environments.
36
+
37
+ Note: This class only works with pre-built images. It does not support
38
+ building images on-the-fly from a base image.
39
+
40
+ Example:
41
+ with ApptainerWorkspace(
42
+ server_image="ghcr.io/openhands/agent-server:latest-python"
43
+ ) as workspace:
44
+ result = workspace.execute_command("ls -la")
45
+ """
46
+
47
+ # Override parent fields with defaults
48
+ working_dir: str = Field(
49
+ default="/workspace",
50
+ description="Working directory inside the container.",
51
+ )
52
+ host: str = Field(
53
+ default="",
54
+ description=("Remote host URL (set automatically during container startup)."),
55
+ )
56
+
57
+ # Apptainer-specific configuration
58
+ server_image: str | None = Field(
59
+ default=None,
60
+ description="Pre-built agent server image to use.",
61
+ )
62
+ sif_file: str | None = Field(
63
+ default=None,
64
+ description=(
65
+ "Path to existing Apptainer SIF file. If provided, skips image pull. "
66
+ "Mutually exclusive with server_image."
67
+ ),
68
+ )
69
+ host_port: int | None = Field(
70
+ default=None,
71
+ description="Port to bind the container to. If None, finds available port.",
72
+ )
73
+ forward_env: list[str] = Field(
74
+ default_factory=lambda: ["DEBUG"],
75
+ description="Environment variables to forward to the container.",
76
+ )
77
+ mount_dir: str | None = Field(
78
+ default=None,
79
+ description="Optional host directory to mount into the container.",
80
+ )
81
+ detach_logs: bool = Field(
82
+ default=True, description="Whether to stream container logs in background."
83
+ )
84
+ platform: PlatformType = Field(
85
+ default="linux/amd64", description="Platform for the Docker image."
86
+ )
87
+ extra_ports: bool = Field(
88
+ default=False,
89
+ description="Whether to expose additional ports (VSCode, VNC).",
90
+ )
91
+ cache_dir: str | None = Field(
92
+ default=None,
93
+ description=(
94
+ "Directory for Apptainer cache and SIF files. "
95
+ "Defaults to ~/.apptainer_cache"
96
+ ),
97
+ )
98
+ use_fakeroot: bool = Field(
99
+ default=True,
100
+ description=(
101
+ "Whether to use --fakeroot for consistent file ownership. "
102
+ "Set to False if fakeroot is not supported in your environment."
103
+ ),
104
+ )
105
+
106
+ enable_docker_compat: bool = Field(
107
+ default=True,
108
+ description=(
109
+ "Whether to use --compat for maximum Docker compatibility. "
110
+ "Check this URL for documentation: "
111
+ "https://apptainer.org/docs/user/main/docker_and_oci.html#docker-like-compat-flag"
112
+ " Set to False if you want custom Apptainer behavior."
113
+ ),
114
+ )
115
+
116
+ disable_mount_locations: list[str] = Field(
117
+ default=["hostfs", "bind-paths"],
118
+ description=(
119
+ "List of locations to disable mounting for. "
120
+ "Helpful for disabling system-level mounts/binds from apptainer.conf. "
121
+ "Check this URL for documentation: "
122
+ "https://apptainer.org/docs/user/main/bind_paths_and_mounts.html. "
123
+ "Specify locations to disable mounts for custom Apptainer behavior."
124
+ ),
125
+ )
126
+
127
+ _instance_name: str | None = PrivateAttr(default=None)
128
+ _logs_thread: threading.Thread | None = PrivateAttr(default=None)
129
+ _stop_logs: threading.Event = PrivateAttr(default_factory=threading.Event)
130
+ _sif_path: str = PrivateAttr()
131
+ _process: subprocess.Popen[str] | None = PrivateAttr(default=None)
132
+
133
+ def model_post_init(self, context: Any) -> None:
134
+ """Set up the Apptainer container and initialize the remote workspace."""
135
+ # Validate that exactly one of server_image or sif_file is provided
136
+ # This must be done here (not in model_validator) because model_post_init
137
+ # runs before model_validator in Pydantic
138
+ sources = [self.server_image, self.sif_file]
139
+ if sum(x is not None for x in sources) != 1:
140
+ raise ValueError("Exactly one of 'server_image' or 'sif_file' must be set.")
141
+
142
+ # Determine port
143
+ if self.host_port is None:
144
+ self.host_port = find_available_tcp_port()
145
+ else:
146
+ self.host_port = int(self.host_port)
147
+
148
+ if not check_port_available(self.host_port):
149
+ raise RuntimeError(f"Port {self.host_port} is not available")
150
+
151
+ if self.extra_ports:
152
+ if not check_port_available(self.host_port + 1):
153
+ raise RuntimeError(
154
+ f"Port {self.host_port + 1} is not available for VSCode"
155
+ )
156
+ if not check_port_available(self.host_port + 2):
157
+ raise RuntimeError(
158
+ f"Port {self.host_port + 2} is not available for VNC"
159
+ )
160
+
161
+ # Ensure apptainer is available
162
+ apptainer_ver = execute_command(["apptainer", "version"]).returncode
163
+ if apptainer_ver != 0:
164
+ raise RuntimeError(
165
+ "Apptainer is not available. Please install Apptainer from "
166
+ "https://apptainer.org/docs/user/main/quick_start.html"
167
+ )
168
+
169
+ # Set up cache directory
170
+ if self.cache_dir is None:
171
+ self.cache_dir = str(Path.home() / ".apptainer_cache")
172
+ os.makedirs(self.cache_dir, exist_ok=True)
173
+
174
+ # Build or use existing SIF file
175
+ if self.sif_file:
176
+ if not Path(self.sif_file).exists():
177
+ raise RuntimeError(f"SIF file not found: {self.sif_file}")
178
+ self._sif_path = self.sif_file
179
+ logger.info("Using existing SIF file: %s", self._sif_path)
180
+ else:
181
+ self._sif_path = self._prepare_sif_image()
182
+
183
+ # Run container
184
+ self._instance_name = f"agent-server-{uuid.uuid4()}"
185
+ self._start_container()
186
+
187
+ # Set host for RemoteWorkspace to use
188
+ object.__setattr__(self, "host", f"http://localhost:{self.host_port}")
189
+ # Apptainer inherits SESSION_API_KEY from environment by default
190
+ # We need to match it if present
191
+ session_api_key = os.environ.get("SESSION_API_KEY")
192
+ object.__setattr__(self, "api_key", session_api_key)
193
+
194
+ # Wait for container to be healthy
195
+ self._wait_for_health()
196
+ logger.info("Apptainer workspace is ready at %s", self.host)
197
+
198
+ # Now initialize the parent RemoteWorkspace with the container URL
199
+ super().model_post_init(context)
200
+
201
+ def _prepare_sif_image(self) -> str:
202
+ """Prepare the SIF image file from server_image."""
203
+ if self.server_image is None:
204
+ raise RuntimeError("server_image must be set")
205
+
206
+ docker_image = self.server_image
207
+
208
+ # Convert Docker image to SIF
209
+ assert self.cache_dir is not None, "cache_dir must be set in model_post_init"
210
+ sif_name = docker_image.replace(":", "_").replace("/", "_") + ".sif"
211
+ sif_path = os.path.join(self.cache_dir, sif_name)
212
+
213
+ if Path(sif_path).exists():
214
+ logger.info("Using cached SIF file: %s", sif_path)
215
+ return sif_path
216
+
217
+ logger.info("Pulling and converting Docker image to SIF: %s", docker_image)
218
+ # Use apptainer pull to directly convert from Docker registry
219
+ # This doesn't require Docker daemon
220
+ pull_cmd = [
221
+ "apptainer",
222
+ "pull",
223
+ sif_path,
224
+ f"docker://{docker_image}",
225
+ ]
226
+ proc = execute_command(pull_cmd)
227
+ if proc.returncode != 0:
228
+ raise RuntimeError(
229
+ f"Failed to pull and convert Docker image: {proc.stderr}"
230
+ )
231
+
232
+ logger.info("SIF file created: %s", sif_path)
233
+ return sif_path
234
+
235
+ def _start_container(self) -> None:
236
+ """Start the Apptainer container instance."""
237
+ # Prepare environment variables
238
+ env_args: list[str] = []
239
+ for key in self.forward_env:
240
+ if key in os.environ:
241
+ env_args += ["--env", f"{key}={os.environ[key]}"]
242
+
243
+ # Prepare bind mounts
244
+ bind_args: list[str] = []
245
+ if self.mount_dir:
246
+ mount_path = "/workspace"
247
+ bind_args += ["--bind", f"{self.mount_dir}:{mount_path}"]
248
+ logger.info(
249
+ "Mounting host dir %s to container path %s",
250
+ self.mount_dir,
251
+ mount_path,
252
+ )
253
+
254
+ # Build container options
255
+ container_opts: list[str] = []
256
+
257
+ # Add fakeroot for consistent file ownership (user appears as root)
258
+ if self.use_fakeroot:
259
+ container_opts.append("--fakeroot")
260
+ if self.enable_docker_compat:
261
+ container_opts.append("--compat")
262
+ if self.disable_mount_locations:
263
+ for loc in self.disable_mount_locations:
264
+ container_opts += [
265
+ "--no-mount",
266
+ loc,
267
+ ] # Disable specified mount locations
268
+
269
+ # Run the agent server using apptainer run to respect the image's entrypoint
270
+ # This works with both 'source' and 'binary' build targets
271
+ # Uses the pre-configured entrypoints from agent-server Dockerfile
272
+ server_cmd = [
273
+ "apptainer",
274
+ "run",
275
+ *container_opts,
276
+ *env_args,
277
+ *bind_args,
278
+ self._sif_path,
279
+ "--host",
280
+ "0.0.0.0",
281
+ "--port",
282
+ str(self.host_port),
283
+ ]
284
+
285
+ # Start the server process in the background
286
+ self._process = subprocess.Popen(
287
+ server_cmd,
288
+ stdout=subprocess.PIPE,
289
+ stderr=subprocess.STDOUT,
290
+ text=True,
291
+ )
292
+
293
+ # Optionally stream logs in background
294
+ if self.detach_logs:
295
+ self._logs_thread = threading.Thread(target=self._stream_logs, daemon=True)
296
+ self._logs_thread.start()
297
+
298
+ def _stream_logs(self) -> None:
299
+ """Stream container logs to stdout in the background."""
300
+ if not self._process or not self._process.stdout:
301
+ return
302
+ try:
303
+ for line in iter(self._process.stdout.readline, ""):
304
+ if self._stop_logs.is_set():
305
+ break
306
+ if line:
307
+ sys.stdout.write(f"[APPTAINER] {line}")
308
+ sys.stdout.flush()
309
+ except Exception as e:
310
+ sys.stderr.write(f"Error streaming apptainer logs: {e}\n")
311
+ finally:
312
+ try:
313
+ self._stop_logs.set()
314
+ except Exception:
315
+ pass
316
+
317
+ def _wait_for_health(self, timeout: float = 120.0) -> None:
318
+ """Wait for the container to become healthy."""
319
+ start = time.time()
320
+ health_url = f"http://127.0.0.1:{self.host_port}/health"
321
+
322
+ while time.time() - start < timeout:
323
+ try:
324
+ with urlopen(health_url, timeout=1.0) as resp:
325
+ if 200 <= getattr(resp, "status", 200) < 300:
326
+ return
327
+ except Exception:
328
+ pass
329
+
330
+ # Check if process is still running
331
+ if self._process and self._process.poll() is not None:
332
+ # Process has terminated
333
+ raise RuntimeError(
334
+ f"Container process stopped unexpectedly with "
335
+ f"exit code {self._process.returncode}"
336
+ )
337
+
338
+ time.sleep(1)
339
+ raise RuntimeError("Container failed to become healthy in time")
340
+
341
+ def __enter__(self) -> "ApptainerWorkspace":
342
+ """Context manager entry - returns the workspace itself."""
343
+ return self
344
+
345
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
346
+ """Context manager exit - cleans up the Apptainer container."""
347
+ self.cleanup()
348
+
349
+ def __del__(self) -> None:
350
+ """Clean up the Apptainer container when the workspace is destroyed."""
351
+ # Guard against accessing private attributes during interpreter shutdown
352
+ if getattr(self, "__pydantic_private__", None) is not None:
353
+ self.cleanup()
354
+
355
+ def cleanup(self) -> None:
356
+ """Stop and remove the Apptainer container."""
357
+ if getattr(self, "_instance_name", None):
358
+ # Stop logs streaming
359
+ self._stop_logs.set()
360
+ if self._logs_thread and self._logs_thread.is_alive():
361
+ self._logs_thread.join(timeout=2)
362
+
363
+ # Terminate the server process if running
364
+ if self._process:
365
+ try:
366
+ logger.info("Terminating Apptainer process...")
367
+ self._process.terminate()
368
+ self._process.wait(timeout=5)
369
+ except Exception as e:
370
+ logger.warning("Error terminating process: %s", e)
371
+ try:
372
+ self._process.kill()
373
+ except Exception:
374
+ pass
375
+
376
+ self._process = None
377
+ self._instance_name = None
@@ -0,0 +1,6 @@
1
+ """OpenHands Cloud workspace implementation."""
2
+
3
+ from .workspace import OpenHandsCloudWorkspace
4
+
5
+
6
+ __all__ = ["OpenHandsCloudWorkspace"]