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.
- openhands/workspace/__init__.py +33 -0
- openhands/workspace/apptainer/__init__.py +6 -0
- openhands/workspace/apptainer/workspace.py +377 -0
- openhands/workspace/cloud/__init__.py +6 -0
- openhands/workspace/cloud/workspace.py +374 -0
- openhands/workspace/docker/__init__.py +20 -0
- openhands/workspace/docker/dev_workspace.py +116 -0
- openhands/workspace/docker/workspace.py +410 -0
- openhands/workspace/py.typed +0 -0
- openhands/workspace/remote_api/__init__.py +6 -0
- openhands/workspace/remote_api/workspace.py +416 -0
- openhands_workspace-1.11.0.dist-info/METADATA +12 -0
- openhands_workspace-1.11.0.dist-info/RECORD +15 -0
- {openhands_workspace-1.0.0a1.dist-info → openhands_workspace-1.11.0.dist-info}/WHEEL +1 -1
- openhands_workspace-1.11.0.dist-info/top_level.txt +1 -0
- openhands_workspace-1.0.0a1.dist-info/METADATA +0 -7
- openhands_workspace-1.0.0a1.dist-info/RECORD +0 -4
- openhands_workspace-1.0.0a1.dist-info/top_level.txt +0 -1
|
@@ -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,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
|