strix-agent 0.1.9__py3-none-any.whl → 0.1.11__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.
- strix/agents/StrixAgent/strix_agent.py +18 -6
- strix/agents/StrixAgent/system_prompt.jinja +26 -7
- strix/agents/base_agent.py +3 -0
- strix/cli/app.py +3 -1
- strix/cli/main.py +85 -1
- strix/cli/tool_components/terminal_renderer.py +92 -60
- strix/llm/llm.py +3 -3
- strix/runtime/docker_runtime.py +204 -160
- strix/runtime/runtime.py +3 -2
- strix/runtime/tool_server.py +136 -28
- strix/tools/agents_graph/agents_graph_actions.py +4 -4
- strix/tools/agents_graph/agents_graph_actions_schema.xml +17 -1
- strix/tools/argument_parser.py +2 -1
- strix/tools/executor.py +3 -0
- strix/tools/terminal/__init__.py +2 -2
- strix/tools/terminal/terminal_actions.py +22 -40
- strix/tools/terminal/terminal_actions_schema.xml +113 -88
- strix/tools/terminal/terminal_manager.py +83 -123
- strix/tools/terminal/terminal_session.py +447 -0
- {strix_agent-0.1.9.dist-info → strix_agent-0.1.11.dist-info}/METADATA +4 -15
- {strix_agent-0.1.9.dist-info → strix_agent-0.1.11.dist-info}/RECORD +24 -24
- strix/tools/terminal/terminal_instance.py +0 -231
- {strix_agent-0.1.9.dist-info → strix_agent-0.1.11.dist-info}/LICENSE +0 -0
- {strix_agent-0.1.9.dist-info → strix_agent-0.1.11.dist-info}/WHEEL +0 -0
- {strix_agent-0.1.9.dist-info → strix_agent-0.1.11.dist-info}/entry_points.txt +0 -0
strix/runtime/docker_runtime.py
CHANGED
@@ -7,19 +7,15 @@ from pathlib import Path
|
|
7
7
|
from typing import cast
|
8
8
|
|
9
9
|
import docker
|
10
|
-
from docker.errors import DockerException, NotFound
|
10
|
+
from docker.errors import DockerException, ImageNotFound, NotFound
|
11
11
|
from docker.models.containers import Container
|
12
12
|
|
13
13
|
from .runtime import AbstractRuntime, SandboxInfo
|
14
14
|
|
15
15
|
|
16
|
-
|
17
|
-
STRIX_SCAN_LABEL = "StrixScan_ID"
|
18
|
-
STRIX_IMAGE = os.getenv("STRIX_IMAGE", "ghcr.io/usestrix/strix-sandbox:0.1.4")
|
16
|
+
STRIX_IMAGE = os.getenv("STRIX_IMAGE", "ghcr.io/usestrix/strix-sandbox:0.1.10")
|
19
17
|
logger = logging.getLogger(__name__)
|
20
18
|
|
21
|
-
_initialized_volumes: set[str] = set()
|
22
|
-
|
23
19
|
|
24
20
|
class DockerRuntime(AbstractRuntime):
|
25
21
|
def __init__(self) -> None:
|
@@ -29,9 +25,18 @@ class DockerRuntime(AbstractRuntime):
|
|
29
25
|
logger.exception("Failed to connect to Docker daemon")
|
30
26
|
raise RuntimeError("Docker is not available or not configured correctly.") from e
|
31
27
|
|
28
|
+
self._scan_container: Container | None = None
|
29
|
+
self._tool_server_port: int | None = None
|
30
|
+
self._tool_server_token: str | None = None
|
31
|
+
|
32
32
|
def _generate_sandbox_token(self) -> str:
|
33
33
|
return secrets.token_urlsafe(32)
|
34
34
|
|
35
|
+
def _find_available_port(self) -> int:
|
36
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
37
|
+
s.bind(("", 0))
|
38
|
+
return cast("int", s.getsockname()[1])
|
39
|
+
|
35
40
|
def _get_scan_id(self, agent_id: str) -> str:
|
36
41
|
try:
|
37
42
|
from strix.cli.tracer import get_global_tracer
|
@@ -46,37 +51,151 @@ class DockerRuntime(AbstractRuntime):
|
|
46
51
|
|
47
52
|
return f"scan-{agent_id.split('-')[0]}"
|
48
53
|
|
49
|
-
def
|
50
|
-
|
51
|
-
|
52
|
-
|
54
|
+
def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:
|
55
|
+
def _validate_image(image: docker.models.images.Image) -> None:
|
56
|
+
if not image.id or not image.attrs:
|
57
|
+
raise ImageNotFound(f"Image {image_name} metadata incomplete")
|
58
|
+
|
59
|
+
for attempt in range(max_retries):
|
60
|
+
try:
|
61
|
+
image = self.client.images.get(image_name)
|
62
|
+
_validate_image(image)
|
63
|
+
except ImageNotFound:
|
64
|
+
if attempt == max_retries - 1:
|
65
|
+
logger.exception(f"Image {image_name} not found after {max_retries} attempts")
|
66
|
+
raise
|
67
|
+
logger.warning(f"Image {image_name} not ready, attempt {attempt + 1}/{max_retries}")
|
68
|
+
time.sleep(2**attempt)
|
69
|
+
except DockerException:
|
70
|
+
if attempt == max_retries - 1:
|
71
|
+
logger.exception(f"Failed to verify image {image_name}")
|
72
|
+
raise
|
73
|
+
logger.warning(f"Docker error verifying image, attempt {attempt + 1}/{max_retries}")
|
74
|
+
time.sleep(2**attempt)
|
75
|
+
else:
|
76
|
+
logger.debug(f"Image {image_name} verified as available")
|
77
|
+
return
|
53
78
|
|
54
|
-
def
|
55
|
-
|
79
|
+
def _create_container_with_retry(self, scan_id: str, max_retries: int = 3) -> Container:
|
80
|
+
last_exception = None
|
56
81
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
82
|
+
for attempt in range(max_retries):
|
83
|
+
try:
|
84
|
+
self._verify_image_available(STRIX_IMAGE)
|
85
|
+
|
86
|
+
caido_port = self._find_available_port()
|
87
|
+
tool_server_port = self._find_available_port()
|
88
|
+
tool_server_token = self._generate_sandbox_token()
|
89
|
+
|
90
|
+
self._tool_server_port = tool_server_port
|
91
|
+
self._tool_server_token = tool_server_token
|
92
|
+
|
93
|
+
container = self.client.containers.run(
|
94
|
+
STRIX_IMAGE,
|
95
|
+
command="sleep infinity",
|
96
|
+
detach=True,
|
97
|
+
name=f"strix-scan-{scan_id}",
|
98
|
+
hostname=f"strix-scan-{scan_id}",
|
99
|
+
ports={
|
100
|
+
f"{caido_port}/tcp": caido_port,
|
101
|
+
f"{tool_server_port}/tcp": tool_server_port,
|
102
|
+
},
|
103
|
+
cap_add=["NET_ADMIN", "NET_RAW"],
|
104
|
+
labels={"strix-scan-id": scan_id},
|
105
|
+
environment={
|
106
|
+
"PYTHONUNBUFFERED": "1",
|
107
|
+
"CAIDO_PORT": str(caido_port),
|
108
|
+
"TOOL_SERVER_PORT": str(tool_server_port),
|
109
|
+
"TOOL_SERVER_TOKEN": tool_server_token,
|
110
|
+
},
|
111
|
+
tty=True,
|
67
112
|
)
|
68
|
-
return cast("Container", containers[0])
|
69
|
-
except DockerException as e:
|
70
|
-
logger.warning("Failed to get sandbox by agent ID %s: %s", agent_id, e)
|
71
|
-
return None
|
72
113
|
|
73
|
-
|
114
|
+
self._scan_container = container
|
115
|
+
logger.info("Created container %s for scan %s", container.id, scan_id)
|
116
|
+
|
117
|
+
self._initialize_container(
|
118
|
+
container, caido_port, tool_server_port, tool_server_token
|
119
|
+
)
|
120
|
+
except DockerException as e:
|
121
|
+
last_exception = e
|
122
|
+
if attempt == max_retries - 1:
|
123
|
+
logger.exception(f"Failed to create container after {max_retries} attempts")
|
124
|
+
break
|
125
|
+
|
126
|
+
logger.warning(f"Container creation attempt {attempt + 1}/{max_retries} failed")
|
127
|
+
|
128
|
+
self._tool_server_port = None
|
129
|
+
self._tool_server_token = None
|
130
|
+
|
131
|
+
sleep_time = (2**attempt) + (0.1 * attempt)
|
132
|
+
time.sleep(sleep_time)
|
133
|
+
else:
|
134
|
+
return container
|
135
|
+
|
136
|
+
raise RuntimeError(
|
137
|
+
f"Failed to create Docker container after {max_retries} attempts: {last_exception}"
|
138
|
+
) from last_exception
|
139
|
+
|
140
|
+
def _get_or_create_scan_container(self, scan_id: str) -> Container:
|
141
|
+
if self._scan_container:
|
142
|
+
try:
|
143
|
+
self._scan_container.reload()
|
144
|
+
if self._scan_container.status == "running":
|
145
|
+
return self._scan_container
|
146
|
+
except NotFound:
|
147
|
+
self._scan_container = None
|
148
|
+
self._tool_server_port = None
|
149
|
+
self._tool_server_token = None
|
150
|
+
|
74
151
|
try:
|
75
|
-
self.client.
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
152
|
+
containers = self.client.containers.list(filters={"label": f"strix-scan-id={scan_id}"})
|
153
|
+
if containers:
|
154
|
+
container = cast("Container", containers[0])
|
155
|
+
if container.status != "running":
|
156
|
+
container.start()
|
157
|
+
time.sleep(2)
|
158
|
+
self._scan_container = container
|
159
|
+
|
160
|
+
for env_var in container.attrs["Config"]["Env"]:
|
161
|
+
if env_var.startswith("TOOL_SERVER_PORT="):
|
162
|
+
self._tool_server_port = int(env_var.split("=")[1])
|
163
|
+
elif env_var.startswith("TOOL_SERVER_TOKEN="):
|
164
|
+
self._tool_server_token = env_var.split("=")[1]
|
165
|
+
|
166
|
+
return container
|
167
|
+
except DockerException as e:
|
168
|
+
logger.warning("Failed to find existing container for scan %s: %s", scan_id, e)
|
169
|
+
|
170
|
+
logger.info("Creating new Docker container for scan %s", scan_id)
|
171
|
+
return self._create_container_with_retry(scan_id)
|
172
|
+
|
173
|
+
def _initialize_container(
|
174
|
+
self, container: Container, caido_port: int, tool_server_port: int, tool_server_token: str
|
175
|
+
) -> None:
|
176
|
+
logger.info("Initializing Caido proxy on port %s", caido_port)
|
177
|
+
result = container.exec_run(
|
178
|
+
f"bash -c 'export CAIDO_PORT={caido_port} && /usr/local/bin/docker-entrypoint.sh true'",
|
179
|
+
detach=False,
|
180
|
+
)
|
181
|
+
|
182
|
+
time.sleep(5)
|
183
|
+
|
184
|
+
result = container.exec_run(
|
185
|
+
"bash -c 'source /etc/profile.d/proxy.sh && echo $CAIDO_API_TOKEN'", user="pentester"
|
186
|
+
)
|
187
|
+
caido_token = result.output.decode().strip() if result.exit_code == 0 else ""
|
188
|
+
|
189
|
+
container.exec_run(
|
190
|
+
f"bash -c 'source /etc/profile.d/proxy.sh && cd /app && "
|
191
|
+
f"STRIX_SANDBOX_MODE=true CAIDO_API_TOKEN={caido_token} CAIDO_PORT={caido_port} "
|
192
|
+
f"poetry run python strix/runtime/tool_server.py --token {tool_server_token} "
|
193
|
+
f"--host 0.0.0.0 --port {tool_server_port} &'",
|
194
|
+
detach=True,
|
195
|
+
user="pentester",
|
196
|
+
)
|
197
|
+
|
198
|
+
time.sleep(5)
|
80
199
|
|
81
200
|
def _copy_local_directory_to_container(self, container: Container, local_path: str) -> None:
|
82
201
|
import tarfile
|
@@ -85,10 +204,10 @@ class DockerRuntime(AbstractRuntime):
|
|
85
204
|
try:
|
86
205
|
local_path_obj = Path(local_path).resolve()
|
87
206
|
if not local_path_obj.exists() or not local_path_obj.is_dir():
|
88
|
-
logger.warning(f"Local path does not exist or is not
|
207
|
+
logger.warning(f"Local path does not exist or is not directory: {local_path_obj}")
|
89
208
|
return
|
90
209
|
|
91
|
-
logger.info(f"Copying local directory {local_path_obj} to container
|
210
|
+
logger.info(f"Copying local directory {local_path_obj} to container")
|
92
211
|
|
93
212
|
tar_buffer = BytesIO()
|
94
213
|
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
@@ -98,18 +217,14 @@ class DockerRuntime(AbstractRuntime):
|
|
98
217
|
tar.add(item, arcname=arcname)
|
99
218
|
|
100
219
|
tar_buffer.seek(0)
|
101
|
-
|
102
|
-
container.put_archive("/shared_workspace", tar_buffer.getvalue())
|
220
|
+
container.put_archive("/workspace", tar_buffer.getvalue())
|
103
221
|
|
104
222
|
container.exec_run(
|
105
|
-
"chown -R pentester:pentester /
|
223
|
+
"chown -R pentester:pentester /workspace && chmod -R 755 /workspace",
|
106
224
|
user="root",
|
107
225
|
)
|
108
226
|
|
109
|
-
logger.info(
|
110
|
-
f"Successfully copied {local_path_obj} to /shared_workspace in container "
|
111
|
-
f"{container.id}"
|
112
|
-
)
|
227
|
+
logger.info("Successfully copied local directory to /workspace")
|
113
228
|
|
114
229
|
except (OSError, DockerException):
|
115
230
|
logger.exception("Failed to copy local directory to container")
|
@@ -117,94 +232,56 @@ class DockerRuntime(AbstractRuntime):
|
|
117
232
|
async def create_sandbox(
|
118
233
|
self, agent_id: str, existing_token: str | None = None, local_source_path: str | None = None
|
119
234
|
) -> SandboxInfo:
|
120
|
-
sandbox = self._get_sandbox_by_agent_id(agent_id)
|
121
|
-
auth_token = existing_token or self._generate_sandbox_token()
|
122
|
-
|
123
235
|
scan_id = self._get_scan_id(agent_id)
|
124
|
-
|
125
|
-
|
126
|
-
self._ensure_workspace_volume(volume_name)
|
127
|
-
|
128
|
-
if not sandbox:
|
129
|
-
logger.info("Creating new Docker sandbox for agent %s", agent_id)
|
130
|
-
try:
|
131
|
-
tool_server_port = self._find_available_port()
|
132
|
-
caido_port = self._find_available_port()
|
236
|
+
container = self._get_or_create_scan_container(scan_id)
|
133
237
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
STRIX_IMAGE,
|
139
|
-
command="sleep infinity",
|
140
|
-
detach=True,
|
141
|
-
name=container_name,
|
142
|
-
hostname=container_name,
|
143
|
-
ports={
|
144
|
-
f"{tool_server_port}/tcp": tool_server_port,
|
145
|
-
f"{caido_port}/tcp": caido_port,
|
146
|
-
},
|
147
|
-
cap_add=["NET_ADMIN", "NET_RAW"],
|
148
|
-
labels={
|
149
|
-
STRIX_AGENT_LABEL: agent_id,
|
150
|
-
STRIX_SCAN_LABEL: scan_id,
|
151
|
-
},
|
152
|
-
environment={
|
153
|
-
"PYTHONUNBUFFERED": "1",
|
154
|
-
"STRIX_AGENT_ID": agent_id,
|
155
|
-
"STRIX_SANDBOX_TOKEN": auth_token,
|
156
|
-
"STRIX_TOOL_SERVER_PORT": str(tool_server_port),
|
157
|
-
"CAIDO_PORT": str(caido_port),
|
158
|
-
},
|
159
|
-
volumes=volumes_config,
|
160
|
-
tty=True,
|
161
|
-
)
|
162
|
-
logger.info(
|
163
|
-
"Created new sandbox %s for agent %s with shared workspace %s",
|
164
|
-
sandbox.id,
|
165
|
-
agent_id,
|
166
|
-
volume_name,
|
167
|
-
)
|
168
|
-
except DockerException as e:
|
169
|
-
raise RuntimeError(f"Failed to create Docker sandbox: {e}") from e
|
238
|
+
source_copied_key = f"_source_copied_{scan_id}"
|
239
|
+
if local_source_path and not hasattr(self, source_copied_key):
|
240
|
+
self._copy_local_directory_to_container(container, local_source_path)
|
241
|
+
setattr(self, source_copied_key, True)
|
170
242
|
|
171
|
-
|
172
|
-
if
|
173
|
-
|
174
|
-
time.sleep(15)
|
243
|
+
container_id = container.id
|
244
|
+
if container_id is None:
|
245
|
+
raise RuntimeError("Docker container ID is unexpectedly None")
|
175
246
|
|
176
|
-
if
|
177
|
-
self._copy_local_directory_to_container(sandbox, local_source_path)
|
178
|
-
_initialized_volumes.add(volume_name)
|
247
|
+
token = existing_token if existing_token is not None else self._tool_server_token
|
179
248
|
|
180
|
-
|
181
|
-
|
182
|
-
raise RuntimeError("Docker container ID is unexpectedly None")
|
249
|
+
if self._tool_server_port is None or token is None:
|
250
|
+
raise RuntimeError("Tool server not initialized or no token available")
|
183
251
|
|
184
|
-
|
185
|
-
next(
|
186
|
-
(
|
187
|
-
i
|
188
|
-
for i, s in enumerate(sandbox.attrs["Config"]["Env"])
|
189
|
-
if s.startswith("STRIX_TOOL_SERVER_PORT=")
|
190
|
-
),
|
191
|
-
-1,
|
192
|
-
)
|
193
|
-
].split("=")[1]
|
194
|
-
tool_server_port = int(tool_server_port_str)
|
252
|
+
api_url = await self.get_sandbox_url(container_id, self._tool_server_port)
|
195
253
|
|
196
|
-
|
254
|
+
await self._register_agent_with_tool_server(api_url, agent_id, token)
|
197
255
|
|
198
256
|
return {
|
199
|
-
"workspace_id":
|
257
|
+
"workspace_id": container_id,
|
200
258
|
"api_url": api_url,
|
201
|
-
"auth_token":
|
202
|
-
"tool_server_port":
|
259
|
+
"auth_token": token,
|
260
|
+
"tool_server_port": self._tool_server_port,
|
261
|
+
"agent_id": agent_id,
|
203
262
|
}
|
204
263
|
|
205
|
-
async def
|
264
|
+
async def _register_agent_with_tool_server(
|
265
|
+
self, api_url: str, agent_id: str, token: str
|
266
|
+
) -> None:
|
267
|
+
import httpx
|
268
|
+
|
269
|
+
try:
|
270
|
+
async with httpx.AsyncClient() as client:
|
271
|
+
response = await client.post(
|
272
|
+
f"{api_url}/register_agent",
|
273
|
+
params={"agent_id": agent_id},
|
274
|
+
headers={"Authorization": f"Bearer {token}"},
|
275
|
+
timeout=30,
|
276
|
+
)
|
277
|
+
response.raise_for_status()
|
278
|
+
logger.info(f"Registered agent {agent_id} with tool server")
|
279
|
+
except (httpx.RequestError, httpx.HTTPStatusError) as e:
|
280
|
+
logger.warning(f"Failed to register agent {agent_id}: {e}")
|
281
|
+
|
282
|
+
async def get_sandbox_url(self, container_id: str, port: int) -> str:
|
206
283
|
try:
|
207
|
-
container = self.client.containers.get(
|
284
|
+
container = self.client.containers.get(container_id)
|
208
285
|
container.reload()
|
209
286
|
|
210
287
|
host = "localhost"
|
@@ -214,58 +291,25 @@ class DockerRuntime(AbstractRuntime):
|
|
214
291
|
host = docker_host.split("://")[1].split(":")[0]
|
215
292
|
|
216
293
|
except NotFound:
|
217
|
-
raise ValueError(f"
|
294
|
+
raise ValueError(f"Container {container_id} not found.") from None
|
218
295
|
except DockerException as e:
|
219
|
-
raise RuntimeError(f"Failed to get
|
296
|
+
raise RuntimeError(f"Failed to get container URL for {container_id}: {e}") from e
|
220
297
|
else:
|
221
298
|
return f"http://{host}:{port}"
|
222
299
|
|
223
|
-
async def destroy_sandbox(self,
|
224
|
-
logger.info("Destroying
|
300
|
+
async def destroy_sandbox(self, container_id: str) -> None:
|
301
|
+
logger.info("Destroying scan container %s", container_id)
|
225
302
|
try:
|
226
|
-
container = self.client.containers.get(
|
227
|
-
|
228
|
-
scan_id = None
|
229
|
-
if container.labels and STRIX_SCAN_LABEL in container.labels:
|
230
|
-
scan_id = container.labels[STRIX_SCAN_LABEL]
|
231
|
-
|
303
|
+
container = self.client.containers.get(container_id)
|
232
304
|
container.stop()
|
233
305
|
container.remove()
|
234
|
-
logger.info("Successfully destroyed
|
306
|
+
logger.info("Successfully destroyed container %s", container_id)
|
235
307
|
|
236
|
-
|
237
|
-
|
308
|
+
self._scan_container = None
|
309
|
+
self._tool_server_port = None
|
310
|
+
self._tool_server_token = None
|
238
311
|
|
239
312
|
except NotFound:
|
240
|
-
logger.warning("
|
313
|
+
logger.warning("Container %s not found for destruction.", container_id)
|
241
314
|
except DockerException as e:
|
242
|
-
logger.warning("Failed to destroy
|
243
|
-
|
244
|
-
async def _cleanup_workspace_if_empty(self, scan_id: str) -> None:
|
245
|
-
try:
|
246
|
-
volume_name = self._get_workspace_volume_name(scan_id)
|
247
|
-
|
248
|
-
containers = self.client.containers.list(
|
249
|
-
all=True, filters={"label": f"{STRIX_SCAN_LABEL}={scan_id}"}
|
250
|
-
)
|
251
|
-
|
252
|
-
if not containers:
|
253
|
-
try:
|
254
|
-
volume = self.client.volumes.get(volume_name)
|
255
|
-
volume.remove()
|
256
|
-
logger.info(
|
257
|
-
f"Cleaned up workspace volume {volume_name} for completed scan {scan_id}"
|
258
|
-
)
|
259
|
-
|
260
|
-
_initialized_volumes.discard(volume_name)
|
261
|
-
|
262
|
-
except NotFound:
|
263
|
-
logger.debug(f"Volume {volume_name} already removed")
|
264
|
-
except DockerException as e:
|
265
|
-
logger.warning(f"Failed to remove volume {volume_name}: {e}")
|
266
|
-
|
267
|
-
except DockerException as e:
|
268
|
-
logger.warning("Error during workspace cleanup for scan %s: %s", scan_id, e)
|
269
|
-
|
270
|
-
async def cleanup_scan_workspace(self, scan_id: str) -> None:
|
271
|
-
await self._cleanup_workspace_if_empty(scan_id)
|
315
|
+
logger.warning("Failed to destroy container %s: %s", container_id, e)
|
strix/runtime/runtime.py
CHANGED
@@ -7,6 +7,7 @@ class SandboxInfo(TypedDict):
|
|
7
7
|
api_url: str
|
8
8
|
auth_token: str | None
|
9
9
|
tool_server_port: int
|
10
|
+
agent_id: str
|
10
11
|
|
11
12
|
|
12
13
|
class AbstractRuntime(ABC):
|
@@ -17,9 +18,9 @@ class AbstractRuntime(ABC):
|
|
17
18
|
raise NotImplementedError
|
18
19
|
|
19
20
|
@abstractmethod
|
20
|
-
async def get_sandbox_url(self,
|
21
|
+
async def get_sandbox_url(self, container_id: str, port: int) -> str:
|
21
22
|
raise NotImplementedError
|
22
23
|
|
23
24
|
@abstractmethod
|
24
|
-
async def destroy_sandbox(self,
|
25
|
+
async def destroy_sandbox(self, container_id: str) -> None:
|
25
26
|
raise NotImplementedError
|