inspect-sandboxes 0.1.1.dev1__tar.gz → 0.2.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.
Files changed (28) hide show
  1. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/PKG-INFO +1 -1
  2. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/pyproject.toml +5 -1
  3. inspect_sandboxes-0.2.0/src/inspect_sandboxes/_version.py +24 -0
  4. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/README.md +167 -0
  5. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/__init__.py +1 -0
  6. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/daytona/_compose.py +99 -18
  7. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_daytona.py +314 -0
  8. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_dind_env.py +359 -0
  9. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_dind_project.py +509 -0
  10. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_retry.py +79 -0
  11. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_sandbox_utils.py +108 -0
  12. inspect_sandboxes-0.2.0/src/inspect_sandboxes/daytona/_single_env.py +239 -0
  13. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/README.md +8 -0
  14. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/_compose.py +39 -4
  15. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/_modal.py +44 -9
  16. inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/_version.py +0 -34
  17. inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/daytona/README.md +0 -92
  18. inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/daytona/__init__.py +0 -1
  19. inspect_sandboxes-0.1.1.dev1/src/inspect_sandboxes/daytona/_daytona.py +0 -490
  20. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/.gitignore +0 -0
  21. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/LICENSE +0 -0
  22. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/README.md +0 -0
  23. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/__init__.py +0 -0
  24. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/_registry.py +0 -0
  25. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/_util/__init__.py +0 -0
  26. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/_util/compose.py +0 -0
  27. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/modal/__init__.py +0 -0
  28. {inspect_sandboxes-0.1.1.dev1 → inspect_sandboxes-0.2.0}/src/inspect_sandboxes/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inspect_sandboxes
3
- Version: 0.1.1.dev1
3
+ Version: 0.2.0
4
4
  Summary: Collection of sandboxes for Inspect AI
5
5
  Project-URL: Source Code, https://github.com/meridianlabs-ai/inspect_sandboxes
6
6
  Project-URL: Issue Tracker, https://github.com/meridianlabs-ai/inspect_sandboxes/issues
@@ -102,6 +102,9 @@ ignore = [
102
102
  "D415", # first line should end with a period, question mark, or exclamation point
103
103
  ]
104
104
 
105
+ [tool.ruff.lint.per-file-ignores]
106
+ "tests/**" = ["D103"] # missing docstring in public function
107
+
105
108
  [tool.ruff.lint.pydocstyle]
106
109
  convention = "google"
107
110
 
@@ -111,6 +114,7 @@ relative_files = true
111
114
  [tool.pytest.ini_options]
112
115
  pythonpath = ["src"]
113
116
  testpaths = ["tests"]
117
+ addopts = "-m 'not integration'"
114
118
  markers = [
115
- "integration: marks tests that require real Modal resources (deselect with '-m \"not integration\"')",
119
+ "integration: marks tests that require real Modal/Daytona resources (run with '-m integration')",
116
120
  ]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,167 @@
1
+ # Daytona Sandbox
2
+
3
+ Cloud development environment sandbox for [Inspect AI](https://inspect.ai-safety-institute.org.uk/) using [Daytona](https://www.daytona.io/).
4
+
5
+ ## Setup
6
+
7
+ Create a Daytona account and set your API key:
8
+
9
+ ```bash
10
+ export DAYTONA_API_KEY=your_api_key
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Default snapshot
16
+
17
+ ```python
18
+ from inspect_ai import Task, eval
19
+ from inspect_ai.solver import generate, system_message
20
+
21
+ task = Task(
22
+ dataset=[{"input": "What is 2+2?", "target": "4"}],
23
+ solver=[
24
+ system_message("You are a helpful assistant."),
25
+ generate(),
26
+ ],
27
+ sandbox="daytona", # Uses Daytona's default snapshot
28
+ )
29
+
30
+ eval(task)
31
+ ```
32
+
33
+ ### Dockerfile
34
+
35
+ ```python
36
+ task = Task(
37
+ dataset=[...],
38
+ solver=[...],
39
+ sandbox=("daytona", "path/to/Dockerfile"),
40
+ )
41
+ ```
42
+
43
+ ### Single-service Docker Compose
44
+
45
+ ```yaml
46
+ # compose.yaml
47
+ services:
48
+ default:
49
+ image: python:3.12
50
+ environment:
51
+ - MY_VAR=hello
52
+ deploy:
53
+ resources:
54
+ limits:
55
+ cpus: "2.0"
56
+ memory: 4g
57
+ reservations:
58
+ devices:
59
+ - capabilities: [gpu]
60
+ count: 1
61
+ ```
62
+
63
+ ```python
64
+ task = Task(
65
+ dataset=[...],
66
+ solver=[...],
67
+ sandbox=("daytona", "compose.yaml"),
68
+ )
69
+ ```
70
+
71
+ ### Multi-service Docker Compose (DinD)
72
+
73
+ When a compose file defines more than one service, the provider automatically
74
+ uses Docker-in-Docker: a single Daytona sandbox runs a Docker daemon, and
75
+ services are brought up via `docker compose` inside it. Each service is
76
+ exposed as a separate `SandboxEnvironment`.
77
+
78
+ ```yaml
79
+ # compose.yaml
80
+ services:
81
+ default:
82
+ image: python:3.12
83
+ x-default: true
84
+ helper:
85
+ image: redis:7
86
+ ```
87
+
88
+ ```python
89
+ task = Task(
90
+ dataset=[...],
91
+ solver=[...],
92
+ sandbox=("daytona", "compose.yaml"),
93
+ )
94
+
95
+ # In a solver, access services by name:
96
+ default_env = sandbox() # the x-default service
97
+ helper_env = sandbox("helper")
98
+ ```
99
+
100
+ **Default service selection** (priority): `x-default: true` > service named
101
+ `"default"` or `"main"` > first service in the file.
102
+
103
+ **Resources**: Per-service resources are summed across all services plus 1 CPU
104
+ and 1 GiB overhead for the Docker daemon. Ensure the total fits within your
105
+ Daytona per-sandbox limits.
106
+
107
+ **DinD image**: The DinD sandbox uses `docker:28.3.3-dind` as the base image.
108
+
109
+ **DinD snapshots**: The provider auto-creates a Daytona snapshot for the DinD
110
+ base image. You can also provide a pre-created snapshot:
111
+
112
+ ```yaml
113
+ x-daytona:
114
+ snapshot: "my-dind-snapshot"
115
+ ```
116
+
117
+ **Image caching**: Each DinD sandbox gets a fresh Docker daemon with no image
118
+ cache. `docker compose build` rebuilds from scratch every sample. For faster
119
+ startup, pre-build your images and push them to a registry, then use `image:`
120
+ instead of `build:` in your compose file:
121
+
122
+ ```yaml
123
+ services:
124
+ default:
125
+ image: myregistry/myapp:latest # pulled, not built
126
+ verifier:
127
+ image: myregistry/verifier:latest
128
+ ```
129
+
130
+ *Daytona does not support snapshotting a running sandbox (to capture Docker's
131
+ layer cache) or shared volumes across sandboxes, so registry-based caching is
132
+ the recommended approach.*
133
+
134
+ ### x-daytona extensions
135
+
136
+ ```yaml
137
+ x-daytona:
138
+ auto_stop_interval: 10 # Minutes of inactivity before auto-stop (0 = disabled)
139
+ auto_archive_interval: 60 # Minutes before stopped sandbox auto-archives
140
+ auto_delete_interval: 1440 # Minutes before stopped sandbox auto-deletes
141
+ network_block_all: false # Block all network access
142
+ network_allow_list: "10.0.0.0/8,192.168.0.0/16" # Comma-separated CIDR allowlist
143
+ language: "python" # Hint for language-aware features
144
+ os_user: "ubuntu" # OS user for commands (overrides service-level user)
145
+ public: false # Publicly accessible sandbox
146
+ ephemeral: true # Auto-delete when stopped
147
+ timeout: 60.0 # Seconds to wait for sandbox creation
148
+ snapshot: "my-snapshot" # Pre-created snapshot (skips image build)
149
+ resources: # Sandbox-level resource overrides
150
+ cpu: 4 # (DinD: overrides per-service aggregation)
151
+ memory: 8
152
+ gpu: 1
153
+ env_vars: # Extra env vars (single-service: merged with service
154
+ EXTRA_VAR: "value" # environment; DinD: set on the VM, not compose services)
155
+ labels: # Custom labels, merged with inspect's own labels
156
+ team: "research"
157
+ ```
158
+
159
+ ## Notes and Limitations
160
+
161
+ - **Default user**: The default sandbox user is `daytona` (not root), with passwordless `sudo`.
162
+ - **user**: The `user` parameter to `exec()` is supported via `sudo -u` in single-service mode and `docker compose exec --user` in DinD mode. Numeric UIDs are supported. Requires `sudo` with passwordless access (configured by default).
163
+ - **stdin**: The `input` parameter to `exec()` is supported via input redirection from a temp file. POSIX-compatible.
164
+ - **stderr**: The Daytona API returns combined stdout+stderr; `stderr` is always empty.
165
+ - **Architecture**: Daytona runners use `linux/amd64`. arm64-only images are not supported.
166
+ - **Network**: Outbound internet depends on your Daytona subscription tier. Tiers 1-2 restrict to essential services (Docker Hub, npm, PyPI, GitHub, AI providers). Tiers 3-4 have full internet. Docker Compose `network_mode` is translated to Daytona's `network_block_all`; `x-daytona.network_block_all` takes precedence. For DinD, the VM always has network enabled (for `docker pull`); service-level isolation is via compose `network_mode`.
167
+ - **DinD startup latency**: Docker daemon boot + image pulls + compose up can take 30s+. Use pre-built images and snapshots to reduce this.
@@ -0,0 +1 @@
1
+ """Daytona sandbox implementation for Inspect AI"""
@@ -2,7 +2,12 @@ import math
2
2
  from pathlib import Path
3
3
  from typing import Any
4
4
 
5
- from daytona_sdk import Image, Resources
5
+ from daytona_sdk import (
6
+ CreateSandboxFromImageParams,
7
+ CreateSandboxFromSnapshotParams,
8
+ Image,
9
+ Resources,
10
+ )
6
11
  from inspect_ai.util import ComposeConfig, ComposeService
7
12
 
8
13
  from inspect_sandboxes._util.compose import (
@@ -12,25 +17,20 @@ from inspect_sandboxes._util.compose import (
12
17
  )
13
18
 
14
19
 
15
- def convert_compose_to_daytona_params(
16
- config: ComposeConfig, compose_path: str | None
17
- ) -> tuple[str | Image, Resources | None, dict[str, Any]]:
18
- """Convert a ComposeConfig to Daytona sandbox creation parameters.
20
+ def create_single_service_params(
21
+ config: ComposeConfig,
22
+ compose_path: str | None,
23
+ labels: dict[str, str],
24
+ ) -> CreateSandboxFromImageParams | CreateSandboxFromSnapshotParams:
25
+ """Create Daytona sandbox params from a single-service compose config.
19
26
 
20
27
  Args:
21
28
  config: Parsed compose configuration.
22
29
  compose_path: Path to the compose file for resolving relative paths.
23
30
  Pass None when using a ComposeConfig object directly.
24
-
25
- Returns:
26
- Tuple of (image, resources, sandbox_params) where:
27
- - image: Docker image name or Image object
28
- - resources: Resources config, or None if not specified
29
- - sandbox_params: Extra sandbox params (env_vars, network settings, etc.)
31
+ labels: Labels to apply (merged with x-daytona labels).
30
32
  """
31
- service = next((svc for svc in config.services.values() if svc.x_default), None)
32
- if service is None:
33
- service = config.services.get("default") or next(iter(config.services.values()))
33
+ _, service = find_default_service(config)
34
34
 
35
35
  compose_dir = Path(compose_path).parent if compose_path else Path.cwd()
36
36
 
@@ -45,7 +45,7 @@ def convert_compose_to_daytona_params(
45
45
  else:
46
46
  raise ValueError("Compose service must specify either 'image' or 'build'")
47
47
 
48
- # Resources
48
+ # Resources (from service, may be overridden by x-daytona.resources below)
49
49
  resources = _service_to_resources(service)
50
50
 
51
51
  # Sandbox-level params
@@ -57,12 +57,87 @@ def convert_compose_to_daytona_params(
57
57
  if service.user:
58
58
  sandbox_params["os_user"] = service.user
59
59
 
60
- _apply_daytona_extensions(sandbox_params, config.extensions)
60
+ # Translate Docker network_mode to Daytona network_block_all.
61
+ # Only set as a default; x-daytona extensions can override below.
62
+ if service.network_mode is not None and "network_block_all" not in sandbox_params:
63
+ sandbox_params["network_block_all"] = service.network_mode == "none"
64
+
65
+ apply_daytona_extensions(sandbox_params, config.extensions)
66
+
67
+ sandbox_params.setdefault("auto_stop_interval", 0)
68
+ x_labels = sandbox_params.pop("labels", {})
69
+ merged_labels = {**x_labels, **labels}
70
+
71
+ # x-daytona.resources overrides service-level resources
72
+ resources_override = sandbox_params.pop("resources", None)
73
+ if resources_override:
74
+ resources = Resources(
75
+ cpu=resources_override.get("cpu"),
76
+ memory=resources_override.get("memory"),
77
+ gpu=resources_override.get("gpu"),
78
+ )
79
+
80
+ # x-daytona.snapshot: use pre-built snapshot instead of building from image
81
+ snapshot = sandbox_params.pop("snapshot", None)
82
+ if snapshot:
83
+ return CreateSandboxFromSnapshotParams(
84
+ snapshot=snapshot,
85
+ labels=merged_labels,
86
+ **sandbox_params,
87
+ )
88
+
89
+ return CreateSandboxFromImageParams(
90
+ image=image,
91
+ resources=resources,
92
+ labels=merged_labels,
93
+ **sandbox_params,
94
+ )
95
+
96
+
97
+ def find_default_service(config: ComposeConfig) -> tuple[str, ComposeService]:
98
+ """Find the default service in a compose config.
99
+
100
+ Priority: x-default: true -> service named "default" or "main" -> first service.
101
+
102
+ Returns:
103
+ Tuple of (service_name, service_config).
104
+ """
105
+ for name, svc in config.services.items():
106
+ if svc.x_default:
107
+ return name, svc
108
+ for candidate in ("default", "main"):
109
+ if candidate in config.services:
110
+ return candidate, config.services[candidate]
111
+ name = next(iter(config.services))
112
+ return name, config.services[name]
113
+
114
+
115
+ def aggregate_resources(config: ComposeConfig) -> Resources | None:
116
+ """Sum per-service resources across all services + (Docker daemon) overhead."""
117
+ total_cpu = 0
118
+ total_memory = 0
119
+ total_gpu = 0
120
+ has_any = False
121
+
122
+ for svc in config.services.values():
123
+ r = _service_to_resources(svc)
124
+ if r:
125
+ has_any = True
126
+ total_cpu += r.cpu or 0
127
+ total_memory += r.memory or 0
128
+ total_gpu += r.gpu or 0
129
+
130
+ if not has_any:
131
+ return None
61
132
 
62
- return image, resources, sandbox_params
133
+ return Resources(
134
+ cpu=total_cpu + 1,
135
+ memory=total_memory + 1,
136
+ gpu=total_gpu or None,
137
+ )
63
138
 
64
139
 
65
- def _apply_daytona_extensions(
140
+ def apply_daytona_extensions(
66
141
  params: dict[str, Any], extensions: dict[str, Any]
67
142
  ) -> None:
68
143
  """Apply Daytona-specific extensions from x-daytona compose key.
@@ -83,6 +158,10 @@ def _apply_daytona_extensions(
83
158
  x-daytona values take precedence over service-level environment.
84
159
  - labels (dict): Custom labels. Merged by the caller with inspect's own labels,
85
160
  which take precedence.
161
+ - snapshot (str): Pre-created Daytona snapshot name. For single-service,
162
+ skips image building. For DinD, uses as the DinD VM snapshot.
163
+ - resources (dict): Sandbox-level resource overrides (cpu, memory, gpu).
164
+ For DinD, overrides the per-service aggregation.
86
165
  - volumes: Not yet supported.
87
166
 
88
167
  Args:
@@ -103,6 +182,8 @@ def _apply_daytona_extensions(
103
182
  "ephemeral",
104
183
  "timeout",
105
184
  "labels",
185
+ "snapshot",
186
+ "resources",
106
187
  ]
107
188
 
108
189
  for key in simple_keys:
@@ -0,0 +1,314 @@
1
+ """Daytona sandbox provider"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import uuid
8
+ from contextvars import ContextVar
9
+ from logging import getLogger
10
+
11
+ from daytona_sdk import (
12
+ AsyncDaytona,
13
+ CreateSandboxFromImageParams,
14
+ CreateSandboxFromSnapshotParams,
15
+ DaytonaNotFoundError,
16
+ Image,
17
+ )
18
+ from inspect_ai.util import (
19
+ ComposeConfig,
20
+ SandboxEnvironment,
21
+ SandboxEnvironmentConfigType,
22
+ is_compose_yaml,
23
+ is_dockerfile,
24
+ parse_compose_yaml,
25
+ sandboxenv,
26
+ trace_message,
27
+ )
28
+ from rich import box, print
29
+ from rich.prompt import Confirm
30
+ from rich.table import Table
31
+ from typing_extensions import override
32
+
33
+ from ._compose import create_single_service_params
34
+ from ._dind_env import DaytonaDinDServiceEnvironment
35
+ from ._sandbox_utils import (
36
+ close_client,
37
+ create_sandbox,
38
+ delete_sandbox,
39
+ list_sandboxes,
40
+ )
41
+ from ._single_env import DaytonaSingleServiceEnvironment
42
+
43
+ logger = getLogger(__name__)
44
+
45
+ INSPECT_SANDBOX_LABEL = {"created_by": "inspect-ai"}
46
+
47
+ _daytona_client: ContextVar[AsyncDaytona | None] = ContextVar(
48
+ "daytona_client", default=None
49
+ )
50
+ _running_sandboxes: ContextVar[list[str]] = ContextVar("daytona_running_sandboxes")
51
+ _run_id: ContextVar[str] = ContextVar("daytona_run_id")
52
+
53
+
54
+ def _init_context() -> None:
55
+ _running_sandboxes.set([])
56
+ _run_id.set(uuid.uuid4().hex)
57
+
58
+
59
+ def _run_labels() -> dict[str, str]:
60
+ return {**INSPECT_SANDBOX_LABEL, "inspect_run_id": _run_id.get()}
61
+
62
+
63
+ @sandboxenv(name="daytona")
64
+ class DaytonaSandboxEnvironment(SandboxEnvironment):
65
+ """Daytona sandbox provider
66
+
67
+ Owns all lifecycle class methods. sample_init returns instances of
68
+ DaytonaSingleServiceEnvironment or DaytonaDinDServiceEnvironment.
69
+ """
70
+
71
+ @classmethod
72
+ def config_files(cls) -> list[str]:
73
+ return [
74
+ "compose.yaml",
75
+ "compose.yml",
76
+ "docker-compose.yaml",
77
+ "docker-compose.yml",
78
+ "Dockerfile",
79
+ ]
80
+
81
+ @classmethod
82
+ def is_docker_compatible(cls) -> bool:
83
+ return True
84
+
85
+ @override
86
+ @classmethod
87
+ async def task_init(
88
+ cls,
89
+ task_name: str,
90
+ config: SandboxEnvironmentConfigType | None,
91
+ ) -> None:
92
+ _init_context()
93
+ client = AsyncDaytona()
94
+ _daytona_client.set(client)
95
+
96
+ @override
97
+ @classmethod
98
+ async def sample_init(
99
+ cls,
100
+ task_name: str,
101
+ config: SandboxEnvironmentConfigType | None,
102
+ metadata: dict[str, str],
103
+ ) -> dict[str, SandboxEnvironment]:
104
+ client = _daytona_client.get()
105
+ if client is None:
106
+ raise RuntimeError(
107
+ "Daytona client not initialized. task_init must be called first."
108
+ )
109
+
110
+ params: CreateSandboxFromSnapshotParams | CreateSandboxFromImageParams
111
+
112
+ if config is None:
113
+ params = CreateSandboxFromSnapshotParams(
114
+ snapshot=None,
115
+ labels=_run_labels(),
116
+ auto_stop_interval=0,
117
+ )
118
+ elif is_dockerfile(config):
119
+ image = Image.from_dockerfile(config)
120
+ params = CreateSandboxFromImageParams(
121
+ image=image,
122
+ labels=_run_labels(),
123
+ auto_stop_interval=0,
124
+ )
125
+ elif is_compose_yaml(config) or isinstance(config, ComposeConfig):
126
+ compose_config: ComposeConfig
127
+ compose_file: str | None
128
+ if is_compose_yaml(config):
129
+ compose_config = parse_compose_yaml(config, multiple_services=True)
130
+ compose_file = str(config)
131
+ else:
132
+ assert isinstance(config, ComposeConfig)
133
+ compose_config = config
134
+ compose_file = None
135
+
136
+ if len(compose_config.services) > 1:
137
+ envs = await DaytonaDinDServiceEnvironment.sample_init_dind(
138
+ client, compose_config, compose_file, _run_labels()
139
+ )
140
+ any_env = next(iter(envs.values())).as_type(
141
+ DaytonaDinDServiceEnvironment
142
+ )
143
+ _running_sandboxes.get().append(any_env.project.sandbox.id)
144
+ return envs
145
+ params = create_single_service_params(
146
+ compose_config, compose_file, _run_labels()
147
+ )
148
+ else:
149
+ raise ValueError(
150
+ f"Unrecognized config: {config}. "
151
+ "Expected a compose file (*.yaml/*.yml), Dockerfile, "
152
+ "ComposeConfig object, or None."
153
+ )
154
+
155
+ sandbox = await create_sandbox(client, params)
156
+ _running_sandboxes.get().append(sandbox.id)
157
+
158
+ return {"default": DaytonaSingleServiceEnvironment(sandbox)}
159
+
160
+ @override
161
+ @classmethod
162
+ async def sample_cleanup(
163
+ cls,
164
+ task_name: str,
165
+ config: SandboxEnvironmentConfigType | None,
166
+ environments: dict[str, SandboxEnvironment],
167
+ interrupted: bool,
168
+ ) -> None:
169
+ if not environments:
170
+ return
171
+
172
+ any_env = next(iter(environments.values()))
173
+ if isinstance(any_env, DaytonaDinDServiceEnvironment):
174
+ await DaytonaDinDServiceEnvironment.sample_cleanup(
175
+ task_name, config, environments, interrupted
176
+ )
177
+ else:
178
+ await DaytonaSingleServiceEnvironment.sample_cleanup(
179
+ task_name, config, environments, interrupted
180
+ )
181
+
182
+ @override
183
+ @classmethod
184
+ async def task_cleanup(
185
+ cls,
186
+ task_name: str,
187
+ config: SandboxEnvironmentConfigType | None,
188
+ cleanup: bool,
189
+ ) -> None:
190
+ if not cleanup:
191
+ return
192
+
193
+ client = _daytona_client.get()
194
+ if client is None:
195
+ return
196
+
197
+ failed_ids: list[str] = []
198
+ deleted_ids: set[str] = set()
199
+
200
+ for sandbox_id in _running_sandboxes.get().copy():
201
+ try:
202
+ sandbox = await client.get(sandbox_id)
203
+ await delete_sandbox(client, sandbox)
204
+ deleted_ids.add(sandbox_id)
205
+ trace_message(logger, "daytona", f"Deleted sandbox {sandbox_id}")
206
+ except DaytonaNotFoundError:
207
+ # Already deleted (e.g. by sample_cleanup) — nothing to do.
208
+ deleted_ids.add(sandbox_id)
209
+ trace_message(
210
+ logger,
211
+ "daytona",
212
+ f"Sandbox {sandbox_id} already deleted, skipping.",
213
+ )
214
+ except Exception as e:
215
+ failed_ids.append(sandbox_id)
216
+ logger.error(f"Failed to delete sandbox {sandbox_id}: {e}")
217
+
218
+ # Second pass: find orphaned sandboxes by run label.
219
+ # This catches sandboxes where creation failed (e.g. build_failed)
220
+ # and the ID was never tracked in _running_sandboxes.
221
+ run_id = _run_id.get()
222
+ if run_id:
223
+ try:
224
+ orphans = await list_sandboxes(client, {"inspect_run_id": run_id})
225
+ for sb in orphans:
226
+ if sb.id in deleted_ids:
227
+ continue
228
+ try:
229
+ await delete_sandbox(client, sb)
230
+ trace_message(
231
+ logger,
232
+ "daytona",
233
+ f"Deleted orphaned sandbox {sb.id} (state={sb.state})",
234
+ )
235
+ except Exception as e:
236
+ failed_ids.append(sb.id)
237
+ logger.error(f"Failed to delete orphaned sandbox {sb.id}: {e}")
238
+ except Exception as e:
239
+ logger.warning(f"Failed to list sandboxes for cleanup: {e}")
240
+
241
+ if failed_ids:
242
+ logger.warning(
243
+ f"Failed to cleanup {len(failed_ids)} sandbox(es). "
244
+ f"Failed IDs: {', '.join(failed_ids)}"
245
+ )
246
+
247
+ _running_sandboxes.get().clear()
248
+
249
+ await close_client(client)
250
+ _daytona_client.set(None)
251
+
252
+ @override
253
+ @classmethod
254
+ async def cli_cleanup(cls, id: str | None) -> None:
255
+ client = AsyncDaytona()
256
+ try:
257
+ if id is not None:
258
+ # Single sandbox cleanup
259
+ try:
260
+ sandbox = await client.get(id)
261
+ await delete_sandbox(client, sandbox)
262
+ print(f"Successfully deleted sandbox {id}")
263
+ except Exception as e:
264
+ print(f"[red]Error deleting sandbox {id}: {e}[/red]")
265
+ sys.exit(1)
266
+ else:
267
+ # Bulk cleanup
268
+ sandboxes = await list_sandboxes(client, INSPECT_SANDBOX_LABEL)
269
+
270
+ if not sandboxes:
271
+ print("No Daytona sandboxes found to clean up.")
272
+ return
273
+
274
+ table = Table(
275
+ box=box.SQUARE,
276
+ show_lines=False,
277
+ title_style="bold",
278
+ title_justify="left",
279
+ )
280
+ table.add_column("Sandbox ID")
281
+ for sb in sandboxes:
282
+ table.add_row(sb.id)
283
+ print(table)
284
+
285
+ is_interactive = sys.stdin.isatty()
286
+ is_ci = "CI" in os.environ
287
+ is_pytest = "PYTEST_CURRENT_TEST" in os.environ
288
+
289
+ if is_interactive and not is_ci and not is_pytest:
290
+ if not Confirm.ask(
291
+ f"Are you sure you want to delete ALL {len(sandboxes)} sandbox(es) above?"
292
+ ):
293
+ print("Cancelled.")
294
+ return
295
+
296
+ success_count = 0
297
+ failure_count = 0
298
+
299
+ for sb in sandboxes:
300
+ try:
301
+ await delete_sandbox(client, sb)
302
+ success_count += 1
303
+ except Exception as e:
304
+ print(f"[yellow]Error deleting sandbox {sb.id}: {e}[/yellow]")
305
+ failure_count += 1
306
+
307
+ print(f"\n[green]Successfully deleted: {success_count}[/green]")
308
+ if failure_count > 0:
309
+ print(f"[red]Failed to delete: {failure_count}[/red]")
310
+ sys.exit(1)
311
+ else:
312
+ print("Complete.")
313
+ finally:
314
+ await close_client(client)