forge-agent-runtime 0.1.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.
- forge_agent_runtime-0.1.0.dist-info/METADATA +364 -0
- forge_agent_runtime-0.1.0.dist-info/RECORD +25 -0
- forge_agent_runtime-0.1.0.dist-info/WHEEL +4 -0
- forge_agent_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- forge_agent_runtime-0.1.0.dist-info/licenses/LICENSE +17 -0
- forge_runtime/__init__.py +32 -0
- forge_runtime/api.py +139 -0
- forge_runtime/cli.py +400 -0
- forge_runtime/config.py +41 -0
- forge_runtime/evaluation.py +22 -0
- forge_runtime/graph.py +85 -0
- forge_runtime/models.py +137 -0
- forge_runtime/orchestrator.py +289 -0
- forge_runtime/providers/__init__.py +1 -0
- forge_runtime/providers/anthropic_provider.py +256 -0
- forge_runtime/providers/base.py +10 -0
- forge_runtime/providers/factory.py +46 -0
- forge_runtime/providers/mock.py +24 -0
- forge_runtime/providers/openai_provider.py +264 -0
- forge_runtime/registry.py +245 -0
- forge_runtime/sandbox.py +872 -0
- forge_runtime/sandbox_assets/Dockerfile +51 -0
- forge_runtime/sandbox_assets/__init__.py +1 -0
- forge_runtime/storage.py +82 -0
- forge_runtime/workspace.py +280 -0
forge_runtime/sandbox.py
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""Ephemeral Docker-based command execution for agent tasks.
|
|
2
|
+
|
|
3
|
+
Commands run in a disposable, unprivileged container with configurable network access
|
|
4
|
+
against a sanitized workspace snapshot. Only changed regular files are copied back, and only for
|
|
5
|
+
AgentForge roles with read-write permission.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import tarfile
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from forge_runtime.workspace import WorkspaceError, WorkspaceTools
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SandboxError(RuntimeError):
|
|
29
|
+
"""Docker sandbox configuration or execution failed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SandboxResult:
|
|
34
|
+
exit_code: int
|
|
35
|
+
stdout: str
|
|
36
|
+
stderr: str
|
|
37
|
+
duration_seconds: float
|
|
38
|
+
timed_out: bool = False
|
|
39
|
+
output_truncated: bool = False
|
|
40
|
+
changed_files: list[str] | None = None
|
|
41
|
+
applied_files: list[str] | None = None
|
|
42
|
+
changes_discarded: bool = False
|
|
43
|
+
|
|
44
|
+
def as_dict(self) -> dict[str, Any]:
|
|
45
|
+
data = asdict(self)
|
|
46
|
+
data["changed_files"] = self.changed_files or []
|
|
47
|
+
data["applied_files"] = self.applied_files or []
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DockerSandboxExecutor:
|
|
52
|
+
"""Run allowlisted developer commands in a locked-down Docker container."""
|
|
53
|
+
|
|
54
|
+
ALLOWED_COMMANDS = {
|
|
55
|
+
"bash", "sh", "python", "python3", "pytest", "ruff", "mypy", "coverage",
|
|
56
|
+
"tox", "nox", "uv", "pip", "pip3", "npm", "npx", "node", "git",
|
|
57
|
+
"make", "cmake", "gcc", "g++", "clang", "go", "cargo", "rustc",
|
|
58
|
+
"java", "javac", "mvn", "gradle", "php", "composer", "ruby", "bundle",
|
|
59
|
+
"rspec", "dart", "flutter", "curl", "wget",
|
|
60
|
+
}
|
|
61
|
+
MAX_ARGS = 64
|
|
62
|
+
MAX_ARGUMENT_CHARS = 16_000
|
|
63
|
+
DEFAULT_TIMEOUT_SECONDS = 180
|
|
64
|
+
MAX_TIMEOUT_SECONDS = 600
|
|
65
|
+
DEFAULT_OUTPUT_CHARS = 24_000
|
|
66
|
+
MAX_SNAPSHOT_FILES = 5_000
|
|
67
|
+
MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024
|
|
68
|
+
MAX_SINGLE_FILE_BYTES = 32 * 1024 * 1024
|
|
69
|
+
SENSITIVE_NAMES = WorkspaceTools.SENSITIVE_FILE_NAMES
|
|
70
|
+
SENSITIVE_SUFFIXES = WorkspaceTools.SENSITIVE_SUFFIXES
|
|
71
|
+
EXCLUDED_DIRS = WorkspaceTools.EXCLUDED_DIRS
|
|
72
|
+
IGNORED_FILES = {
|
|
73
|
+
".coverage", "coverage.xml", ".DS_Store", "npm-debug.log",
|
|
74
|
+
"yarn-error.log", "pnpm-debug.log",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
workspace_root: Path | str = ".",
|
|
80
|
+
*,
|
|
81
|
+
image: str = "forge-runtime-sandbox:latest",
|
|
82
|
+
docker_binary: str = "docker",
|
|
83
|
+
cpu_limit: float = 2.0,
|
|
84
|
+
memory_mb: int = 2048,
|
|
85
|
+
pids_limit: int = 128,
|
|
86
|
+
default_timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
87
|
+
max_timeout_seconds: int = MAX_TIMEOUT_SECONDS,
|
|
88
|
+
output_chars: int = DEFAULT_OUTPUT_CHARS,
|
|
89
|
+
network: str = "bridge",
|
|
90
|
+
max_snapshot_files: int = MAX_SNAPSHOT_FILES,
|
|
91
|
+
max_snapshot_bytes: int = MAX_SNAPSHOT_BYTES,
|
|
92
|
+
) -> None:
|
|
93
|
+
self.workspace = WorkspaceTools(workspace_root)
|
|
94
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}", image):
|
|
95
|
+
raise SandboxError("Sandbox image name contains unsupported characters.")
|
|
96
|
+
if not docker_binary or any(char.isspace() for char in docker_binary):
|
|
97
|
+
raise SandboxError("Docker binary must be a single executable name or path.")
|
|
98
|
+
if network not in {"bridge", "none"}:
|
|
99
|
+
raise SandboxError("Sandbox network must be bridge (internet) or none (offline).")
|
|
100
|
+
if not 0.25 <= cpu_limit <= 16:
|
|
101
|
+
raise SandboxError("Sandbox CPU limit must be between 0.25 and 16.")
|
|
102
|
+
if not 256 <= memory_mb <= 32768:
|
|
103
|
+
raise SandboxError("Sandbox memory limit must be between 256 and 32768 MB.")
|
|
104
|
+
if not 32 <= pids_limit <= 2048:
|
|
105
|
+
raise SandboxError("Sandbox PID limit must be between 32 and 2048.")
|
|
106
|
+
if not 1 <= default_timeout_seconds <= max_timeout_seconds <= self.MAX_TIMEOUT_SECONDS:
|
|
107
|
+
raise SandboxError(
|
|
108
|
+
f"Sandbox timeouts must be between 1 and {self.MAX_TIMEOUT_SECONDS} seconds."
|
|
109
|
+
)
|
|
110
|
+
if not 1_000 <= output_chars <= 100_000:
|
|
111
|
+
raise SandboxError("Sandbox output limit must be between 1000 and 100000 characters.")
|
|
112
|
+
if max_snapshot_files < 1 or max_snapshot_bytes < 1:
|
|
113
|
+
raise SandboxError("Sandbox snapshot limits must be positive.")
|
|
114
|
+
self.image = image
|
|
115
|
+
self.docker_binary = docker_binary
|
|
116
|
+
self.cpu_limit = cpu_limit
|
|
117
|
+
self.memory_mb = memory_mb
|
|
118
|
+
self.pids_limit = pids_limit
|
|
119
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
120
|
+
self.max_timeout_seconds = max_timeout_seconds
|
|
121
|
+
self.output_chars = output_chars
|
|
122
|
+
self.network = network
|
|
123
|
+
self.max_snapshot_files = max_snapshot_files
|
|
124
|
+
self.max_snapshot_bytes = max_snapshot_bytes
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _relative(path: Path, root: Path) -> str:
|
|
128
|
+
return path.relative_to(root).as_posix()
|
|
129
|
+
|
|
130
|
+
def _is_excluded(self, relative: Path) -> bool:
|
|
131
|
+
parts = tuple(part.lower() for part in relative.parts)
|
|
132
|
+
if any(part in self.EXCLUDED_DIRS for part in parts):
|
|
133
|
+
return True
|
|
134
|
+
if not parts:
|
|
135
|
+
return False
|
|
136
|
+
filename = parts[-1]
|
|
137
|
+
if filename in self.SENSITIVE_NAMES or filename in self.IGNORED_FILES:
|
|
138
|
+
return True
|
|
139
|
+
if filename.startswith(".env.") and filename != ".env.example":
|
|
140
|
+
return True
|
|
141
|
+
return Path(filename).suffix.lower() in self.SENSITIVE_SUFFIXES
|
|
142
|
+
|
|
143
|
+
def _copy_workspace(self, destination: Path) -> dict[str, str]:
|
|
144
|
+
"""Copy safe regular files and return their content-hash manifest."""
|
|
145
|
+
root = self.workspace.root
|
|
146
|
+
manifest: dict[str, str] = {}
|
|
147
|
+
total_bytes = 0
|
|
148
|
+
for base, dirs, files in os.walk(root, followlinks=False):
|
|
149
|
+
base_path = Path(base)
|
|
150
|
+
relative_dir = base_path.relative_to(root)
|
|
151
|
+
dirs[:] = [
|
|
152
|
+
name for name in dirs
|
|
153
|
+
if not (base_path / name).is_symlink()
|
|
154
|
+
and not self._is_excluded(relative_dir / name)
|
|
155
|
+
]
|
|
156
|
+
target_dir = destination / relative_dir
|
|
157
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
for filename in files:
|
|
159
|
+
source = base_path / filename
|
|
160
|
+
relative_file = relative_dir / filename
|
|
161
|
+
if source.is_symlink() or self._is_excluded(relative_file):
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
file_size = source.stat().st_size
|
|
165
|
+
except OSError:
|
|
166
|
+
continue
|
|
167
|
+
if file_size > self.MAX_SINGLE_FILE_BYTES:
|
|
168
|
+
raise SandboxError(
|
|
169
|
+
f"Workspace file {relative_file.as_posix()} exceeds the "
|
|
170
|
+
f"{self.MAX_SINGLE_FILE_BYTES // (1024 * 1024)} MB snapshot limit."
|
|
171
|
+
)
|
|
172
|
+
total_bytes += file_size
|
|
173
|
+
if len(manifest) >= self.max_snapshot_files:
|
|
174
|
+
raise SandboxError(
|
|
175
|
+
f"Workspace snapshot exceeds {self.max_snapshot_files} files."
|
|
176
|
+
)
|
|
177
|
+
if total_bytes > self.max_snapshot_bytes:
|
|
178
|
+
raise SandboxError(
|
|
179
|
+
"Workspace snapshot exceeds "
|
|
180
|
+
f"{self.max_snapshot_bytes // (1024 * 1024)} MB."
|
|
181
|
+
)
|
|
182
|
+
target = destination / relative_file
|
|
183
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
digest = hashlib.sha256()
|
|
185
|
+
try:
|
|
186
|
+
with source.open("rb") as reader, target.open("wb") as writer:
|
|
187
|
+
while chunk := reader.read(1024 * 1024):
|
|
188
|
+
digest.update(chunk)
|
|
189
|
+
writer.write(chunk)
|
|
190
|
+
source_mode = stat.S_IMODE(source.stat().st_mode)
|
|
191
|
+
target.chmod(source_mode & 0o755)
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
raise SandboxError(f"Could not snapshot {relative_file}: {exc}") from exc
|
|
194
|
+
manifest[relative_file.as_posix()] = digest.hexdigest()
|
|
195
|
+
return manifest
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _hash_file(path: Path) -> str:
|
|
199
|
+
digest = hashlib.sha256()
|
|
200
|
+
with path.open("rb") as handle:
|
|
201
|
+
while chunk := handle.read(1024 * 1024):
|
|
202
|
+
digest.update(chunk)
|
|
203
|
+
return digest.hexdigest()
|
|
204
|
+
|
|
205
|
+
def _collect_changes(
|
|
206
|
+
self,
|
|
207
|
+
snapshot: Path,
|
|
208
|
+
baseline: dict[str, str],
|
|
209
|
+
) -> tuple[list[str], list[str]]:
|
|
210
|
+
changed: list[str] = []
|
|
211
|
+
deleted: list[str] = []
|
|
212
|
+
seen: set[str] = set()
|
|
213
|
+
total_bytes = 0
|
|
214
|
+
for base, dirs, files in os.walk(snapshot, followlinks=False):
|
|
215
|
+
base_path = Path(base)
|
|
216
|
+
relative_dir = base_path.relative_to(snapshot)
|
|
217
|
+
dirs[:] = [
|
|
218
|
+
name for name in dirs
|
|
219
|
+
if not (base_path / name).is_symlink()
|
|
220
|
+
and not self._is_excluded(relative_dir / name)
|
|
221
|
+
]
|
|
222
|
+
for filename in files:
|
|
223
|
+
item = base_path / filename
|
|
224
|
+
relative = relative_dir / filename
|
|
225
|
+
key = relative.as_posix()
|
|
226
|
+
if item.is_symlink() or self._is_excluded(relative):
|
|
227
|
+
continue
|
|
228
|
+
try:
|
|
229
|
+
size = item.stat().st_size
|
|
230
|
+
except OSError:
|
|
231
|
+
continue
|
|
232
|
+
if size > self.MAX_SINGLE_FILE_BYTES:
|
|
233
|
+
raise SandboxError(
|
|
234
|
+
f"Sandbox output file {key} exceeds the per-file size limit."
|
|
235
|
+
)
|
|
236
|
+
seen.add(key)
|
|
237
|
+
total_bytes += size
|
|
238
|
+
if len(seen) > self.max_snapshot_files:
|
|
239
|
+
raise SandboxError(
|
|
240
|
+
f"Sandbox output exceeds the {self.max_snapshot_files}-file limit."
|
|
241
|
+
)
|
|
242
|
+
if total_bytes > self.max_snapshot_bytes:
|
|
243
|
+
raise SandboxError("Sandbox output exceeds the workspace size limit.")
|
|
244
|
+
current_hash = self._hash_file(item)
|
|
245
|
+
if baseline.get(key) != current_hash:
|
|
246
|
+
changed.append(key)
|
|
247
|
+
for key in baseline:
|
|
248
|
+
if key not in seen:
|
|
249
|
+
deleted.append(key)
|
|
250
|
+
return sorted(changed), sorted(deleted)
|
|
251
|
+
|
|
252
|
+
def _safe_destination(self, relative_value: str) -> Path:
|
|
253
|
+
relative = PurePosixPath(relative_value)
|
|
254
|
+
if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
|
|
255
|
+
raise SandboxError(f"Sandbox produced an unsafe output path: {relative_value}")
|
|
256
|
+
if self._is_excluded(Path(*relative.parts)):
|
|
257
|
+
raise SandboxError(f"Sandbox output path is protected: {relative_value}")
|
|
258
|
+
current = self.workspace.root
|
|
259
|
+
for part in relative.parts[:-1]:
|
|
260
|
+
candidate = current / part
|
|
261
|
+
if candidate.is_symlink():
|
|
262
|
+
raise SandboxError(f"Sandbox output parent is a symlink: {relative_value}")
|
|
263
|
+
if candidate.exists() and not candidate.is_dir():
|
|
264
|
+
raise SandboxError(f"Sandbox output parent is not a directory: {relative_value}")
|
|
265
|
+
if not candidate.exists():
|
|
266
|
+
candidate.mkdir()
|
|
267
|
+
current = candidate
|
|
268
|
+
parent = current
|
|
269
|
+
try:
|
|
270
|
+
parent.resolve().relative_to(self.workspace.root)
|
|
271
|
+
except ValueError as exc:
|
|
272
|
+
raise SandboxError(f"Sandbox output escapes workspace: {relative_value}") from exc
|
|
273
|
+
target = parent / relative.name
|
|
274
|
+
if target.is_symlink():
|
|
275
|
+
raise SandboxError(f"Refusing to replace workspace symlink: {relative_value}")
|
|
276
|
+
return target
|
|
277
|
+
|
|
278
|
+
def _apply_changes(self, snapshot: Path, changed: list[str]) -> list[str]:
|
|
279
|
+
applied: list[str] = []
|
|
280
|
+
for relative in changed:
|
|
281
|
+
source = snapshot / Path(*PurePosixPath(relative).parts)
|
|
282
|
+
if source.is_symlink() or not source.is_file():
|
|
283
|
+
continue
|
|
284
|
+
target = self._safe_destination(relative)
|
|
285
|
+
data_size = source.stat().st_size
|
|
286
|
+
if data_size > self.MAX_SINGLE_FILE_BYTES:
|
|
287
|
+
continue
|
|
288
|
+
temporary_name: str | None = None
|
|
289
|
+
try:
|
|
290
|
+
with tempfile.NamedTemporaryFile(
|
|
291
|
+
mode="wb", prefix=".forge-sandbox-", dir=target.parent, delete=False
|
|
292
|
+
) as temporary, source.open("rb") as reader:
|
|
293
|
+
shutil.copyfileobj(reader, temporary)
|
|
294
|
+
temporary.flush()
|
|
295
|
+
os.fsync(temporary.fileno())
|
|
296
|
+
temporary_name = temporary.name
|
|
297
|
+
os.replace(temporary_name, target)
|
|
298
|
+
source_mode = stat.S_IMODE(source.stat().st_mode)
|
|
299
|
+
target.chmod(source_mode & 0o755)
|
|
300
|
+
applied.append(relative)
|
|
301
|
+
except OSError as exc:
|
|
302
|
+
raise SandboxError(f"Could not apply sandbox output {relative}: {exc}") from exc
|
|
303
|
+
finally:
|
|
304
|
+
if temporary_name and os.path.exists(temporary_name):
|
|
305
|
+
os.unlink(temporary_name)
|
|
306
|
+
return applied
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def validate_argv(argv: Any) -> list[str]:
|
|
310
|
+
if not isinstance(argv, list) or not argv or len(argv) > DockerSandboxExecutor.MAX_ARGS:
|
|
311
|
+
raise SandboxError("argv must be a non-empty array with at most 64 strings.")
|
|
312
|
+
if any(not isinstance(item, str) or not item or "\x00" in item for item in argv):
|
|
313
|
+
raise SandboxError("Every command argument must be a non-empty string.")
|
|
314
|
+
if sum(len(item) for item in argv) > DockerSandboxExecutor.MAX_ARGUMENT_CHARS:
|
|
315
|
+
raise SandboxError("Command arguments exceed the 16000-character limit.")
|
|
316
|
+
executable = argv[0]
|
|
317
|
+
if executable not in DockerSandboxExecutor.ALLOWED_COMMANDS:
|
|
318
|
+
allowed = ", ".join(sorted(DockerSandboxExecutor.ALLOWED_COMMANDS))
|
|
319
|
+
raise SandboxError(
|
|
320
|
+
f"Command '{executable}' is not allowed. Available executables: {allowed}."
|
|
321
|
+
)
|
|
322
|
+
return argv
|
|
323
|
+
|
|
324
|
+
def tool_schemas(self) -> list[dict[str, Any]]:
|
|
325
|
+
return [
|
|
326
|
+
{
|
|
327
|
+
"name": "workspace_run_command",
|
|
328
|
+
"description": (
|
|
329
|
+
"Run a developer/test command in an ephemeral Docker sandbox. "
|
|
330
|
+
f"Network mode is {self.network}; bridge mode enables internet access. "
|
|
331
|
+
"The filesystem is isolated to a sanitized project "
|
|
332
|
+
"snapshot, and CPU/memory/process/time limits are enforced. Use argv as "
|
|
333
|
+
"an array (not a shell command string). Changes are copied back only for "
|
|
334
|
+
"read-write roles. Common commands: python -m pytest, pytest, ruff check ., "
|
|
335
|
+
"npm install && npm test, python -m pip install -r requirements.txt, "
|
|
336
|
+
"curl, wget, node --test and git status. Each run is ephemeral; "
|
|
337
|
+
"combine dependency installation and tests in one shell invocation."
|
|
338
|
+
),
|
|
339
|
+
"parameters": {
|
|
340
|
+
"type": "object",
|
|
341
|
+
"properties": {
|
|
342
|
+
"argv": {
|
|
343
|
+
"type": "array",
|
|
344
|
+
"items": {"type": "string"},
|
|
345
|
+
"minItems": 1,
|
|
346
|
+
"maxItems": self.MAX_ARGS,
|
|
347
|
+
},
|
|
348
|
+
"timeout_seconds": {
|
|
349
|
+
"type": "integer",
|
|
350
|
+
"minimum": 1,
|
|
351
|
+
"maximum": self.max_timeout_seconds,
|
|
352
|
+
"default": self.default_timeout_seconds,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
"required": ["argv"],
|
|
356
|
+
"additionalProperties": False,
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
def _prepare_snapshot_owner(self, snapshot: Path) -> None:
|
|
362
|
+
uid = os.getuid() if hasattr(os, "getuid") else 10001
|
|
363
|
+
if uid != 0 or not hasattr(os, "chown"):
|
|
364
|
+
return
|
|
365
|
+
for base, dirs, files in os.walk(snapshot):
|
|
366
|
+
os.chown(base, 10001, 10001)
|
|
367
|
+
for name in dirs:
|
|
368
|
+
with contextlib.suppress(OSError):
|
|
369
|
+
os.chown(Path(base) / name, 10001, 10001, follow_symlinks=False)
|
|
370
|
+
for name in files:
|
|
371
|
+
with contextlib.suppress(OSError):
|
|
372
|
+
os.chown(Path(base) / name, 10001, 10001, follow_symlinks=False)
|
|
373
|
+
|
|
374
|
+
def _docker_args(self, *, container_name: str) -> list[str]:
|
|
375
|
+
uid = os.getuid() if hasattr(os, "getuid") else 10001
|
|
376
|
+
gid = os.getgid() if hasattr(os, "getgid") else 10001
|
|
377
|
+
if uid == 0:
|
|
378
|
+
uid, gid = 10001, 10001
|
|
379
|
+
|
|
380
|
+
workspace_tmpfs_mb = self.max_snapshot_bytes // (1024 * 1024) + 256
|
|
381
|
+
args = [
|
|
382
|
+
"--name", container_name,
|
|
383
|
+
"--network", self.network,
|
|
384
|
+
"--read-only",
|
|
385
|
+
"--cap-drop", "ALL",
|
|
386
|
+
"--security-opt", "no-new-privileges",
|
|
387
|
+
"--pids-limit", str(self.pids_limit),
|
|
388
|
+
"--memory", f"{self.memory_mb}m",
|
|
389
|
+
"--cpus", str(self.cpu_limit),
|
|
390
|
+
"--shm-size", "64m",
|
|
391
|
+
"--tmpfs", f"/workspace:rw,nosuid,nodev,size={workspace_tmpfs_mb}m,mode=1777",
|
|
392
|
+
"--tmpfs", "/tmp:rw,nosuid,nodev,size=512m,mode=1777",
|
|
393
|
+
"--env", "PIP_TARGET=/tmp/python-packages",
|
|
394
|
+
"--env", "PYTHONPATH=/tmp/python-packages",
|
|
395
|
+
"--env", "PIP_CACHE_DIR=/tmp/pip-cache",
|
|
396
|
+
"--user", f"{uid}:{gid}",
|
|
397
|
+
"--workdir", "/workspace",
|
|
398
|
+
"--env", "HOME=/tmp",
|
|
399
|
+
"--env", "TMPDIR=/tmp",
|
|
400
|
+
"--env", "PYTHONDONTWRITEBYTECODE=1",
|
|
401
|
+
"--env", "PYTEST_ADDOPTS=-p no:cacheprovider",
|
|
402
|
+
"--env", "PATH=/workspace/node_modules/.bin:/tmp/python-packages/bin:/usr/local/bin:/usr/bin:/bin",
|
|
403
|
+
]
|
|
404
|
+
dependencies = self.workspace.root / "node_modules"
|
|
405
|
+
if self.network == "bridge":
|
|
406
|
+
# npm can install packages in this disposable writable volume during one command.
|
|
407
|
+
args.extend([
|
|
408
|
+
"--tmpfs",
|
|
409
|
+
"/workspace/node_modules:rw,nosuid,nodev,size=1024m,mode=1777",
|
|
410
|
+
])
|
|
411
|
+
elif dependencies.is_dir() and not dependencies.is_symlink():
|
|
412
|
+
args.extend([
|
|
413
|
+
"--mount",
|
|
414
|
+
f"type=bind,src={dependencies.resolve()},dst=/opt/forge-node_modules,readonly",
|
|
415
|
+
])
|
|
416
|
+
return args
|
|
417
|
+
|
|
418
|
+
async def _run_control(
|
|
419
|
+
self,
|
|
420
|
+
*command: str,
|
|
421
|
+
timeout_seconds: int = 30,
|
|
422
|
+
) -> tuple[bytes, bytes]:
|
|
423
|
+
try:
|
|
424
|
+
process = await asyncio.create_subprocess_exec(
|
|
425
|
+
*command,
|
|
426
|
+
stdout=asyncio.subprocess.PIPE,
|
|
427
|
+
stderr=asyncio.subprocess.PIPE,
|
|
428
|
+
)
|
|
429
|
+
except (FileNotFoundError, OSError) as exc:
|
|
430
|
+
raise SandboxError(f"Could not start Docker command: {exc}") from exc
|
|
431
|
+
try:
|
|
432
|
+
stdout, stderr = await asyncio.wait_for(
|
|
433
|
+
process.communicate(),
|
|
434
|
+
timeout=timeout_seconds,
|
|
435
|
+
)
|
|
436
|
+
except TimeoutError as exc:
|
|
437
|
+
process.kill()
|
|
438
|
+
await process.communicate()
|
|
439
|
+
raise SandboxError(
|
|
440
|
+
f"Docker operation exceeded its {timeout_seconds}-second timeout."
|
|
441
|
+
) from exc
|
|
442
|
+
if process.returncode != 0:
|
|
443
|
+
detail = stderr.decode("utf-8", errors="replace").strip()
|
|
444
|
+
raise SandboxError(
|
|
445
|
+
detail or f"Docker command exited with code {process.returncode}."
|
|
446
|
+
)
|
|
447
|
+
return stdout, stderr
|
|
448
|
+
|
|
449
|
+
async def _read_limited(self, stream: asyncio.StreamReader) -> tuple[bytes, bool]:
|
|
450
|
+
maximum = self.output_chars * 4
|
|
451
|
+
output = bytearray()
|
|
452
|
+
truncated = False
|
|
453
|
+
while chunk := await stream.read(64 * 1024):
|
|
454
|
+
available = maximum - len(output)
|
|
455
|
+
if available > 0:
|
|
456
|
+
output.extend(chunk[:available])
|
|
457
|
+
if len(chunk) > available:
|
|
458
|
+
truncated = True
|
|
459
|
+
return bytes(output), truncated
|
|
460
|
+
|
|
461
|
+
async def _copy_container_workspace(
|
|
462
|
+
self,
|
|
463
|
+
container_name: str,
|
|
464
|
+
destination: Path,
|
|
465
|
+
) -> None:
|
|
466
|
+
"""Stream container workspace tar data and safely extract regular files."""
|
|
467
|
+
process = await asyncio.create_subprocess_exec(
|
|
468
|
+
self.docker_binary,
|
|
469
|
+
"exec",
|
|
470
|
+
container_name,
|
|
471
|
+
"tar",
|
|
472
|
+
"--exclude=./node_modules",
|
|
473
|
+
"-cf",
|
|
474
|
+
"-",
|
|
475
|
+
"-C",
|
|
476
|
+
"/workspace",
|
|
477
|
+
".",
|
|
478
|
+
stdout=asyncio.subprocess.PIPE,
|
|
479
|
+
stderr=asyncio.subprocess.PIPE,
|
|
480
|
+
)
|
|
481
|
+
assert process.stdout is not None
|
|
482
|
+
assert process.stderr is not None
|
|
483
|
+
archive_path: Path | None = None
|
|
484
|
+
|
|
485
|
+
async def save_archive() -> None:
|
|
486
|
+
nonlocal archive_path
|
|
487
|
+
with tempfile.NamedTemporaryFile(
|
|
488
|
+
mode="wb",
|
|
489
|
+
prefix=".forge-output-",
|
|
490
|
+
suffix=".tar",
|
|
491
|
+
dir=destination.parent,
|
|
492
|
+
delete=False,
|
|
493
|
+
) as archive:
|
|
494
|
+
archive_path = Path(archive.name)
|
|
495
|
+
while chunk := await process.stdout.read(1024 * 1024):
|
|
496
|
+
archive.write(chunk)
|
|
497
|
+
|
|
498
|
+
stdout_task = asyncio.create_task(save_archive())
|
|
499
|
+
stderr_task = asyncio.create_task(process.stderr.read())
|
|
500
|
+
try:
|
|
501
|
+
_, stderr_data, _ = await asyncio.wait_for(
|
|
502
|
+
asyncio.gather(stdout_task, stderr_task, process.wait()),
|
|
503
|
+
timeout=120,
|
|
504
|
+
)
|
|
505
|
+
except TimeoutError as exc:
|
|
506
|
+
process.kill()
|
|
507
|
+
await process.wait()
|
|
508
|
+
for task in (stdout_task, stderr_task):
|
|
509
|
+
if not task.done():
|
|
510
|
+
task.cancel()
|
|
511
|
+
await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
|
|
512
|
+
raise SandboxError("Timed out while exporting the sandbox workspace.") from exc
|
|
513
|
+
except OSError as exc:
|
|
514
|
+
if process.returncode is None:
|
|
515
|
+
process.kill()
|
|
516
|
+
await process.wait()
|
|
517
|
+
raise SandboxError(f"Could not export sandbox workspace: {exc}") from exc
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
if process.returncode != 0:
|
|
521
|
+
detail = stderr_data.decode("utf-8", errors="replace").strip()
|
|
522
|
+
raise SandboxError(f"Could not export sandbox workspace: {detail}")
|
|
523
|
+
|
|
524
|
+
if archive_path is None:
|
|
525
|
+
raise SandboxError("Docker did not return a workspace archive.")
|
|
526
|
+
|
|
527
|
+
total_bytes = 0
|
|
528
|
+
file_count = 0
|
|
529
|
+
with tarfile.open(archive_path, mode="r:") as archive:
|
|
530
|
+
for member in archive:
|
|
531
|
+
member_path = PurePosixPath(member.name)
|
|
532
|
+
if member_path.is_absolute() or any(
|
|
533
|
+
part == ".." for part in member_path.parts
|
|
534
|
+
):
|
|
535
|
+
raise SandboxError(
|
|
536
|
+
f"Sandbox returned an unsafe path: {member.name!r}"
|
|
537
|
+
)
|
|
538
|
+
parts = tuple(part for part in member_path.parts if part not in {"", "."})
|
|
539
|
+
if not parts:
|
|
540
|
+
continue
|
|
541
|
+
relative = Path(*parts)
|
|
542
|
+
if self._is_excluded(relative):
|
|
543
|
+
continue
|
|
544
|
+
target = destination / relative
|
|
545
|
+
if member.isdir():
|
|
546
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
547
|
+
continue
|
|
548
|
+
if not member.isfile():
|
|
549
|
+
# Never propagate symlinks, hard links, devices or other special files.
|
|
550
|
+
continue
|
|
551
|
+
if member.size > self.MAX_SINGLE_FILE_BYTES:
|
|
552
|
+
raise SandboxError(
|
|
553
|
+
f"Sandbox output file {relative.as_posix()} exceeds the per-file limit."
|
|
554
|
+
)
|
|
555
|
+
file_count += 1
|
|
556
|
+
total_bytes += member.size
|
|
557
|
+
if file_count > self.max_snapshot_files:
|
|
558
|
+
raise SandboxError(
|
|
559
|
+
f"Sandbox output exceeds the {self.max_snapshot_files}-file limit."
|
|
560
|
+
)
|
|
561
|
+
if total_bytes > self.max_snapshot_bytes:
|
|
562
|
+
raise SandboxError("Sandbox output exceeds the workspace size limit.")
|
|
563
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
564
|
+
source = archive.extractfile(member)
|
|
565
|
+
if source is None:
|
|
566
|
+
continue
|
|
567
|
+
with source, target.open("wb") as output:
|
|
568
|
+
shutil.copyfileobj(source, output)
|
|
569
|
+
target.chmod(member.mode & 0o755)
|
|
570
|
+
finally:
|
|
571
|
+
if archive_path is not None:
|
|
572
|
+
with contextlib.suppress(OSError):
|
|
573
|
+
archive_path.unlink()
|
|
574
|
+
|
|
575
|
+
async def _run_container(
|
|
576
|
+
self,
|
|
577
|
+
snapshot: Path,
|
|
578
|
+
argv: list[str],
|
|
579
|
+
timeout_seconds: int,
|
|
580
|
+
) -> tuple[int, str, str, bool, bool]:
|
|
581
|
+
container_name = f"forge-task-{uuid.uuid4().hex[:16]}"
|
|
582
|
+
create_command = [
|
|
583
|
+
self.docker_binary,
|
|
584
|
+
"run",
|
|
585
|
+
"-d",
|
|
586
|
+
"--rm",
|
|
587
|
+
"--pull=never",
|
|
588
|
+
*self._docker_args(container_name=container_name),
|
|
589
|
+
self.image,
|
|
590
|
+
"sleep",
|
|
591
|
+
str(self.max_timeout_seconds + 60),
|
|
592
|
+
]
|
|
593
|
+
await self._run_control(*create_command, timeout_seconds=30)
|
|
594
|
+
try:
|
|
595
|
+
self._prepare_snapshot_owner(snapshot)
|
|
596
|
+
# The project lives on a bounded tmpfs inside the container, not a host bind mount.
|
|
597
|
+
# Stream the snapshot into the container's tmpfs. docker cp attempts to
|
|
598
|
+
# populate the read-only rootfs and is not reliable for tmpfs destinations.
|
|
599
|
+
archive_path = snapshot.with_name("workspace-input.tar")
|
|
600
|
+
try:
|
|
601
|
+
with tarfile.open(archive_path, "w") as archive:
|
|
602
|
+
for item in snapshot.iterdir():
|
|
603
|
+
archive.add(item, arcname=item.name, recursive=True)
|
|
604
|
+
extract_process = await asyncio.create_subprocess_exec(
|
|
605
|
+
self.docker_binary,
|
|
606
|
+
"exec",
|
|
607
|
+
"-i",
|
|
608
|
+
container_name,
|
|
609
|
+
"tar",
|
|
610
|
+
"-xf",
|
|
611
|
+
"-",
|
|
612
|
+
"-C",
|
|
613
|
+
"/workspace",
|
|
614
|
+
stdin=asyncio.subprocess.PIPE,
|
|
615
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
616
|
+
stderr=asyncio.subprocess.PIPE,
|
|
617
|
+
)
|
|
618
|
+
assert extract_process.stdin is not None
|
|
619
|
+
assert extract_process.stderr is not None
|
|
620
|
+
try:
|
|
621
|
+
with archive_path.open("rb") as archive_file:
|
|
622
|
+
while chunk := archive_file.read(1024 * 1024):
|
|
623
|
+
extract_process.stdin.write(chunk)
|
|
624
|
+
await extract_process.stdin.drain()
|
|
625
|
+
extract_process.stdin.close()
|
|
626
|
+
await extract_process.stdin.wait_closed()
|
|
627
|
+
_, extract_stderr = await asyncio.wait_for(
|
|
628
|
+
extract_process.communicate(),
|
|
629
|
+
timeout=120,
|
|
630
|
+
)
|
|
631
|
+
except (BrokenPipeError, OSError, TimeoutError) as exc:
|
|
632
|
+
if extract_process.returncode is None:
|
|
633
|
+
extract_process.kill()
|
|
634
|
+
await extract_process.wait()
|
|
635
|
+
raise SandboxError(f"Could not transfer workspace snapshot: {exc}") from exc
|
|
636
|
+
if extract_process.returncode != 0:
|
|
637
|
+
detail = extract_stderr.decode("utf-8", errors="replace").strip()
|
|
638
|
+
raise SandboxError(f"Could not extract workspace snapshot: {detail}")
|
|
639
|
+
finally:
|
|
640
|
+
with contextlib.suppress(OSError):
|
|
641
|
+
archive_path.unlink()
|
|
642
|
+
dependencies = self.workspace.root / "node_modules"
|
|
643
|
+
if (
|
|
644
|
+
self.network == "none"
|
|
645
|
+
and dependencies.is_dir()
|
|
646
|
+
and not dependencies.is_symlink()
|
|
647
|
+
):
|
|
648
|
+
await self._run_control(
|
|
649
|
+
self.docker_binary,
|
|
650
|
+
"exec",
|
|
651
|
+
container_name,
|
|
652
|
+
"ln",
|
|
653
|
+
"-s",
|
|
654
|
+
"/opt/forge-node_modules",
|
|
655
|
+
"/workspace/node_modules",
|
|
656
|
+
timeout_seconds=15,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
command = [
|
|
660
|
+
self.docker_binary,
|
|
661
|
+
"exec",
|
|
662
|
+
"--workdir",
|
|
663
|
+
"/workspace",
|
|
664
|
+
container_name,
|
|
665
|
+
"timeout",
|
|
666
|
+
"--signal=TERM",
|
|
667
|
+
"--kill-after=5s",
|
|
668
|
+
f"{timeout_seconds}s",
|
|
669
|
+
*argv,
|
|
670
|
+
]
|
|
671
|
+
try:
|
|
672
|
+
process = await asyncio.create_subprocess_exec(
|
|
673
|
+
*command,
|
|
674
|
+
stdout=asyncio.subprocess.PIPE,
|
|
675
|
+
stderr=asyncio.subprocess.PIPE,
|
|
676
|
+
)
|
|
677
|
+
except (FileNotFoundError, OSError) as exc:
|
|
678
|
+
raise SandboxError(f"Could not start sandbox command: {exc}") from exc
|
|
679
|
+
|
|
680
|
+
assert process.stdout is not None
|
|
681
|
+
assert process.stderr is not None
|
|
682
|
+
stdout_task = asyncio.create_task(self._read_limited(process.stdout))
|
|
683
|
+
stderr_task = asyncio.create_task(self._read_limited(process.stderr))
|
|
684
|
+
host_timed_out = False
|
|
685
|
+
try:
|
|
686
|
+
await asyncio.wait_for(process.wait(), timeout=timeout_seconds + 15)
|
|
687
|
+
except TimeoutError:
|
|
688
|
+
host_timed_out = True
|
|
689
|
+
await self._stop_container(container_name)
|
|
690
|
+
if process.returncode is None:
|
|
691
|
+
process.kill()
|
|
692
|
+
await process.wait()
|
|
693
|
+
stdout_data, stdout_truncated = await stdout_task
|
|
694
|
+
stderr_data, stderr_truncated = await stderr_task
|
|
695
|
+
timed_out = host_timed_out or process.returncode in {124, 137}
|
|
696
|
+
if timed_out:
|
|
697
|
+
stderr_data += b"\\nForge Runtime: command timed out and was stopped."
|
|
698
|
+
|
|
699
|
+
if not host_timed_out:
|
|
700
|
+
# Export into a fresh directory so deleted container files are detectable.
|
|
701
|
+
output_snapshot = snapshot.with_name("sandbox-output")
|
|
702
|
+
output_snapshot.mkdir()
|
|
703
|
+
await self._copy_container_workspace(container_name, output_snapshot)
|
|
704
|
+
shutil.rmtree(snapshot)
|
|
705
|
+
output_snapshot.rename(snapshot)
|
|
706
|
+
return (
|
|
707
|
+
process.returncode if process.returncode is not None else 124,
|
|
708
|
+
stdout_data.decode("utf-8", errors="replace"),
|
|
709
|
+
stderr_data.decode("utf-8", errors="replace"),
|
|
710
|
+
timed_out,
|
|
711
|
+
stdout_truncated or stderr_truncated,
|
|
712
|
+
)
|
|
713
|
+
finally:
|
|
714
|
+
await self._stop_container(container_name)
|
|
715
|
+
|
|
716
|
+
async def _stop_container(self, name: str) -> None:
|
|
717
|
+
for action in ("kill", "rm"):
|
|
718
|
+
command = [self.docker_binary, action]
|
|
719
|
+
if action == "rm":
|
|
720
|
+
command.append("-f")
|
|
721
|
+
command.append(name)
|
|
722
|
+
try:
|
|
723
|
+
process = await asyncio.create_subprocess_exec(
|
|
724
|
+
*command,
|
|
725
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
726
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
727
|
+
)
|
|
728
|
+
await asyncio.wait_for(process.wait(), timeout=5)
|
|
729
|
+
except (OSError, TimeoutError):
|
|
730
|
+
continue
|
|
731
|
+
|
|
732
|
+
async def run(
|
|
733
|
+
self,
|
|
734
|
+
argv: Any,
|
|
735
|
+
*,
|
|
736
|
+
writable: bool,
|
|
737
|
+
timeout_seconds: int | None = None,
|
|
738
|
+
) -> SandboxResult:
|
|
739
|
+
command = self.validate_argv(argv)
|
|
740
|
+
timeout = timeout_seconds or self.default_timeout_seconds
|
|
741
|
+
if not isinstance(timeout, int) or not 1 <= timeout <= self.max_timeout_seconds:
|
|
742
|
+
raise SandboxError(
|
|
743
|
+
f"timeout_seconds must be between 1 and {self.max_timeout_seconds}."
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
started = time.monotonic()
|
|
747
|
+
with tempfile.TemporaryDirectory(prefix="forge-sandbox-") as temporary:
|
|
748
|
+
snapshot = Path(temporary) / "workspace"
|
|
749
|
+
snapshot.mkdir()
|
|
750
|
+
baseline = self._copy_workspace(snapshot)
|
|
751
|
+
exit_code, stdout, stderr, timed_out, truncated = await self._run_container(
|
|
752
|
+
snapshot, command, timeout
|
|
753
|
+
)
|
|
754
|
+
changed, deleted = self._collect_changes(snapshot, baseline)
|
|
755
|
+
applied = self._apply_changes(snapshot, changed) if writable else []
|
|
756
|
+
if deleted:
|
|
757
|
+
stderr += (
|
|
758
|
+
"\nForge Runtime: deletions are not copied back "
|
|
759
|
+
f"({len(deleted)} file(s) left untouched)."
|
|
760
|
+
)
|
|
761
|
+
return SandboxResult(
|
|
762
|
+
exit_code=exit_code,
|
|
763
|
+
stdout=stdout,
|
|
764
|
+
stderr=stderr,
|
|
765
|
+
duration_seconds=round(time.monotonic() - started, 3),
|
|
766
|
+
timed_out=timed_out,
|
|
767
|
+
output_truncated=truncated,
|
|
768
|
+
changed_files=changed,
|
|
769
|
+
applied_files=applied,
|
|
770
|
+
changes_discarded=bool(changed) and not writable,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
async def execute_tool(
|
|
774
|
+
self,
|
|
775
|
+
arguments: dict[str, Any],
|
|
776
|
+
*,
|
|
777
|
+
writable: bool,
|
|
778
|
+
) -> str:
|
|
779
|
+
try:
|
|
780
|
+
result = await self.run(
|
|
781
|
+
arguments.get("argv"),
|
|
782
|
+
writable=writable,
|
|
783
|
+
timeout_seconds=arguments.get("timeout_seconds"),
|
|
784
|
+
)
|
|
785
|
+
data = result.as_dict()
|
|
786
|
+
data["status"] = (
|
|
787
|
+
"timed_out" if result.timed_out
|
|
788
|
+
else "succeeded" if result.exit_code == 0
|
|
789
|
+
else "failed"
|
|
790
|
+
)
|
|
791
|
+
if result.changes_discarded:
|
|
792
|
+
data["note"] = "Role is read-only; sandbox changes were discarded."
|
|
793
|
+
elif result.applied_files:
|
|
794
|
+
data["note"] = "Changed files were copied back to the project workspace."
|
|
795
|
+
return json.dumps(data, ensure_ascii=False)
|
|
796
|
+
except (SandboxError, WorkspaceError) as exc:
|
|
797
|
+
return f"Sandbox error: {exc}"
|
|
798
|
+
|
|
799
|
+
async def check(self) -> dict[str, Any]:
|
|
800
|
+
"""Check Docker Engine connectivity and whether the configured image exists."""
|
|
801
|
+
try:
|
|
802
|
+
process = await asyncio.create_subprocess_exec(
|
|
803
|
+
self.docker_binary, "info", "--format", "{{.ServerVersion}}",
|
|
804
|
+
stdout=asyncio.subprocess.PIPE,
|
|
805
|
+
stderr=asyncio.subprocess.PIPE,
|
|
806
|
+
)
|
|
807
|
+
except FileNotFoundError as exc:
|
|
808
|
+
raise SandboxError("Docker CLI was not found on PATH.") from exc
|
|
809
|
+
try:
|
|
810
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15)
|
|
811
|
+
except TimeoutError as exc:
|
|
812
|
+
process.kill()
|
|
813
|
+
await process.communicate()
|
|
814
|
+
raise SandboxError("Timed out while checking Docker Engine.") from exc
|
|
815
|
+
if process.returncode != 0:
|
|
816
|
+
detail = stderr.decode("utf-8", errors="replace").strip()
|
|
817
|
+
raise SandboxError(f"Docker Engine is unavailable: {detail}")
|
|
818
|
+
version = stdout.decode("utf-8", errors="replace").strip()
|
|
819
|
+
image_process = await asyncio.create_subprocess_exec(
|
|
820
|
+
self.docker_binary, "image", "inspect", self.image,
|
|
821
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
822
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
823
|
+
)
|
|
824
|
+
try:
|
|
825
|
+
await asyncio.wait_for(image_process.wait(), timeout=15)
|
|
826
|
+
except TimeoutError as exc:
|
|
827
|
+
image_process.kill()
|
|
828
|
+
await image_process.wait()
|
|
829
|
+
raise SandboxError("Timed out while checking the sandbox image.") from exc
|
|
830
|
+
return {
|
|
831
|
+
"docker_server_version": version,
|
|
832
|
+
"image": self.image,
|
|
833
|
+
"image_available": image_process.returncode == 0,
|
|
834
|
+
"workspace": str(self.workspace.root),
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async def build_image(self, dockerfile: Path | None = None) -> str:
|
|
838
|
+
"""Build the bundled, trusted toolchain image."""
|
|
839
|
+
bundled = Path(__file__).resolve().parent / "sandbox_assets" / "Dockerfile"
|
|
840
|
+
dockerfile = dockerfile or bundled
|
|
841
|
+
if not dockerfile.is_file():
|
|
842
|
+
raise SandboxError(f"Sandbox Dockerfile was not found: {dockerfile}")
|
|
843
|
+
command = [
|
|
844
|
+
self.docker_binary,
|
|
845
|
+
"build",
|
|
846
|
+
"--pull",
|
|
847
|
+
"--tag",
|
|
848
|
+
self.image,
|
|
849
|
+
"--file",
|
|
850
|
+
str(dockerfile),
|
|
851
|
+
str(dockerfile.parent),
|
|
852
|
+
]
|
|
853
|
+
try:
|
|
854
|
+
process = await asyncio.create_subprocess_exec(
|
|
855
|
+
*command,
|
|
856
|
+
stdout=asyncio.subprocess.PIPE,
|
|
857
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
858
|
+
)
|
|
859
|
+
except FileNotFoundError as exc:
|
|
860
|
+
raise SandboxError("Docker CLI was not found on PATH.") from exc
|
|
861
|
+
try:
|
|
862
|
+
output, _ = await asyncio.wait_for(
|
|
863
|
+
process.communicate(), timeout=30 * 60
|
|
864
|
+
)
|
|
865
|
+
except TimeoutError as exc:
|
|
866
|
+
process.kill()
|
|
867
|
+
await process.communicate()
|
|
868
|
+
raise SandboxError("Sandbox image build exceeded the 30-minute timeout.") from exc
|
|
869
|
+
text = output.decode("utf-8", errors="replace")
|
|
870
|
+
if process.returncode != 0:
|
|
871
|
+
raise SandboxError(text[-self.output_chars * 2 :])
|
|
872
|
+
return text[-self.output_chars * 2 :]
|