commitarena 0.1.1__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.
- commitarena/__init__.py +40 -0
- commitarena/__main__.py +3 -0
- commitarena/agent.py +205 -0
- commitarena/cli.py +313 -0
- commitarena/demo/LICENSE-sqlparse +25 -0
- commitarena/demo/results.jsonl +33 -0
- commitarena/demo/run.json +36 -0
- commitarena/demo/tasks.json +5603 -0
- commitarena/errors.py +40 -0
- commitarena/llm.py +206 -0
- commitarena/mine.py +248 -0
- commitarena/report.py +132 -0
- commitarena/report_template.html +394 -0
- commitarena/runner.py +298 -0
- commitarena/sandbox.py +180 -0
- commitarena/tasks.py +229 -0
- commitarena/testrun.py +77 -0
- commitarena-0.1.1.dist-info/METADATA +242 -0
- commitarena-0.1.1.dist-info/RECORD +22 -0
- commitarena-0.1.1.dist-info/WHEEL +4 -0
- commitarena-0.1.1.dist-info/entry_points.txt +2 -0
- commitarena-0.1.1.dist-info/licenses/LICENSE +21 -0
commitarena/runner.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Run one task with one model, score it with the original tests, and run whole suites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .agent import AgentConfig, run_agent
|
|
19
|
+
from .errors import BudgetExceeded, CommitArenaError
|
|
20
|
+
from .llm import Budget, ChatClient, ModelSpec
|
|
21
|
+
from .sandbox import Sandbox, SandboxConfig, build_image, ensure_docker, image_exists
|
|
22
|
+
from .tasks import (
|
|
23
|
+
Task,
|
|
24
|
+
changed_files,
|
|
25
|
+
extract_commit,
|
|
26
|
+
init_workspace_git,
|
|
27
|
+
is_test_path,
|
|
28
|
+
load_tasks,
|
|
29
|
+
resolve_repo,
|
|
30
|
+
save_tasks,
|
|
31
|
+
unified_diff,
|
|
32
|
+
write_files,
|
|
33
|
+
)
|
|
34
|
+
from .testrun import run_tests
|
|
35
|
+
|
|
36
|
+
CONTEXT_NAMES = ("AGENTS.md", "CLAUDE.md")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ContextFile:
|
|
41
|
+
"""A context file (AGENTS.md, CLAUDE.md, ...) injected into the attempt.
|
|
42
|
+
|
|
43
|
+
in_repo: write it at the repo root inside the sandbox.
|
|
44
|
+
in_prompt: append its content to the system prompt.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
content: str
|
|
48
|
+
name: str = "AGENTS.md"
|
|
49
|
+
in_repo: bool = True
|
|
50
|
+
in_prompt: bool = True
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_path(cls, path: str | os.PathLike, mode: str = "both", name: str | None = None) -> ContextFile:
|
|
54
|
+
if mode not in ("both", "repo", "prompt"):
|
|
55
|
+
raise CommitArenaError("--context-mode must be one of: both, repo, prompt")
|
|
56
|
+
p = Path(path)
|
|
57
|
+
return cls(p.read_text(), name or p.name, mode in ("both", "repo"), mode in ("both", "prompt"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class RunResult:
|
|
62
|
+
task_id: str
|
|
63
|
+
model: str
|
|
64
|
+
passed: bool
|
|
65
|
+
fail_to_pass_passed: int = 0
|
|
66
|
+
fail_to_pass_total: int = 0
|
|
67
|
+
pass_to_pass_failed: int = 0
|
|
68
|
+
pass_to_pass_total: int = 0
|
|
69
|
+
cost_usd: float = 0.0
|
|
70
|
+
tokens_in: int = 0
|
|
71
|
+
tokens_out: int = 0
|
|
72
|
+
steps: int = 0
|
|
73
|
+
tool_calls: int = 0
|
|
74
|
+
wall_time_s: float = 0.0
|
|
75
|
+
stop_reason: str = ""
|
|
76
|
+
error: str = ""
|
|
77
|
+
patch: str = "" # model's diff on non-test files (what is scored)
|
|
78
|
+
ignored_test_edits: list[str] = field(default_factory=list)
|
|
79
|
+
task_date: str = ""
|
|
80
|
+
context_file: str | None = None
|
|
81
|
+
started_at: str = ""
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return asdict(self)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, d: dict) -> RunResult:
|
|
88
|
+
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ensure_task_image(task: Task) -> None:
|
|
92
|
+
if image_exists(task.image):
|
|
93
|
+
return
|
|
94
|
+
repo = resolve_repo(task.repo)
|
|
95
|
+
tmp = Path(tempfile.mkdtemp(prefix="commitarena-img-"))
|
|
96
|
+
try:
|
|
97
|
+
extract_commit(repo, task.commit, tmp / "snap")
|
|
98
|
+
build_image(task.image, task.env_spec, tmp / "snap", tmp)
|
|
99
|
+
finally:
|
|
100
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def score_workspace(task: Task, pristine: Path, agent_tree: Path, sandbox: SandboxConfig, test_timeout: int):
|
|
104
|
+
"""Apply only the agent's non-test changes onto the pristine workspace and run the suite."""
|
|
105
|
+
changes = changed_files(pristine, agent_tree)
|
|
106
|
+
code = {p: c for p, c in changes.items() if not is_test_path(p)}
|
|
107
|
+
ignored = sorted(p for p in changes if is_test_path(p))
|
|
108
|
+
scored = pristine.parent / "scored"
|
|
109
|
+
shutil.copytree(pristine, scored, ignore=shutil.ignore_patterns(".git"))
|
|
110
|
+
for rel, data in code.items():
|
|
111
|
+
target = scored / rel
|
|
112
|
+
if data is None:
|
|
113
|
+
target.unlink(missing_ok=True)
|
|
114
|
+
else:
|
|
115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
target.write_bytes(data)
|
|
117
|
+
patch = unified_diff(pristine, scored, only=sorted(code))
|
|
118
|
+
with Sandbox(task.image, sandbox) as sb:
|
|
119
|
+
sb.copy_in(scored)
|
|
120
|
+
outcomes = run_tests(sb, test_timeout)
|
|
121
|
+
return outcomes, patch, ignored
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_task(
|
|
125
|
+
task: Task,
|
|
126
|
+
model: str | ModelSpec,
|
|
127
|
+
*,
|
|
128
|
+
budget: Budget | None = None,
|
|
129
|
+
context: ContextFile | None = None,
|
|
130
|
+
strip_context_files: bool = False,
|
|
131
|
+
agent_config: AgentConfig | None = None,
|
|
132
|
+
sandbox_config: SandboxConfig | None = None,
|
|
133
|
+
test_timeout: int = 600,
|
|
134
|
+
on_step: Callable[[int, str], None] | None = None,
|
|
135
|
+
keep_trajectory: list | None = None,
|
|
136
|
+
) -> RunResult:
|
|
137
|
+
"""Run one attempt of `task` with `model` and score it. Raises CommitArenaError on fatal setup errors."""
|
|
138
|
+
spec = model if isinstance(model, ModelSpec) else ModelSpec.parse(model)
|
|
139
|
+
budget = budget or Budget()
|
|
140
|
+
sb_cfg = sandbox_config or SandboxConfig()
|
|
141
|
+
client = ChatClient(spec, budget)
|
|
142
|
+
ensure_docker()
|
|
143
|
+
ensure_task_image(task)
|
|
144
|
+
started = datetime.now(UTC).isoformat(timespec="seconds")
|
|
145
|
+
t0 = time.monotonic()
|
|
146
|
+
tmp = Path(tempfile.mkdtemp(prefix="commitarena-run-"))
|
|
147
|
+
try:
|
|
148
|
+
pristine = tmp / "pristine"
|
|
149
|
+
extract_commit(resolve_repo(task.repo), task.parent, pristine)
|
|
150
|
+
write_files(pristine, task.test_files)
|
|
151
|
+
if strip_context_files:
|
|
152
|
+
for n in CONTEXT_NAMES:
|
|
153
|
+
(pristine / n).unlink(missing_ok=True)
|
|
154
|
+
system_extra = ""
|
|
155
|
+
if context:
|
|
156
|
+
if context.in_repo:
|
|
157
|
+
(pristine / context.name).write_text(context.content)
|
|
158
|
+
if context.in_prompt:
|
|
159
|
+
system_extra = f"# Project instructions ({context.name})\n\n{context.content}"
|
|
160
|
+
workspace = tmp / "workspace"
|
|
161
|
+
shutil.copytree(pristine, workspace)
|
|
162
|
+
init_workspace_git(workspace)
|
|
163
|
+
agent_tree = tmp / "agent"
|
|
164
|
+
with Sandbox(task.image, sb_cfg) as sb:
|
|
165
|
+
sb.copy_in(workspace)
|
|
166
|
+
outcome = run_agent(client, sb, task.prompt, agent_config, system_extra, on_step)
|
|
167
|
+
sb.copy_out(agent_tree)
|
|
168
|
+
if keep_trajectory is not None:
|
|
169
|
+
keep_trajectory.extend(outcome.trajectory)
|
|
170
|
+
outcomes, patch, ignored = score_workspace(task, pristine, agent_tree, sb_cfg, test_timeout)
|
|
171
|
+
passed_set = outcomes.passed()
|
|
172
|
+
f2p_ok = sum(1 for t in task.fail_to_pass if t in passed_set)
|
|
173
|
+
p2p_bad = sum(1 for t in task.pass_to_pass if t not in passed_set)
|
|
174
|
+
return RunResult(
|
|
175
|
+
task_id=task.id,
|
|
176
|
+
model=spec.id,
|
|
177
|
+
passed=outcomes.ok and f2p_ok == len(task.fail_to_pass) and p2p_bad == 0,
|
|
178
|
+
fail_to_pass_passed=f2p_ok,
|
|
179
|
+
fail_to_pass_total=len(task.fail_to_pass),
|
|
180
|
+
pass_to_pass_failed=p2p_bad,
|
|
181
|
+
pass_to_pass_total=len(task.pass_to_pass),
|
|
182
|
+
cost_usd=round(outcome.usage.cost_usd, 6),
|
|
183
|
+
tokens_in=outcome.usage.tokens_in,
|
|
184
|
+
tokens_out=outcome.usage.tokens_out,
|
|
185
|
+
steps=outcome.steps,
|
|
186
|
+
tool_calls=outcome.tool_calls,
|
|
187
|
+
wall_time_s=round(time.monotonic() - t0, 1),
|
|
188
|
+
stop_reason=outcome.stop_reason,
|
|
189
|
+
error=outcome.error,
|
|
190
|
+
patch=patch,
|
|
191
|
+
ignored_test_edits=ignored,
|
|
192
|
+
task_date=task.date,
|
|
193
|
+
context_file=context.name if context else None,
|
|
194
|
+
started_at=started,
|
|
195
|
+
)
|
|
196
|
+
finally:
|
|
197
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def hardware() -> str:
|
|
201
|
+
mem = ""
|
|
202
|
+
try:
|
|
203
|
+
if platform.system() == "Darwin":
|
|
204
|
+
b = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True).stdout)
|
|
205
|
+
chip = subprocess.run(
|
|
206
|
+
["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True
|
|
207
|
+
).stdout.strip()
|
|
208
|
+
mem = f"{chip}, {b // 2**30} GB RAM"
|
|
209
|
+
elif Path("/proc/meminfo").exists():
|
|
210
|
+
kb = int(Path("/proc/meminfo").read_text().split()[1])
|
|
211
|
+
mem = f"{platform.processor() or platform.machine()}, {kb // 2**20} GB RAM"
|
|
212
|
+
except Exception: # noqa: BLE001 - best effort only
|
|
213
|
+
pass
|
|
214
|
+
return f"{platform.system()} {platform.machine()}" + (f" ({mem})" if mem else "")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def run_suite(
|
|
218
|
+
tasks: list[Task],
|
|
219
|
+
models: list[ModelSpec],
|
|
220
|
+
out_dir: Path,
|
|
221
|
+
*,
|
|
222
|
+
budget: Budget,
|
|
223
|
+
context: ContextFile | None = None,
|
|
224
|
+
strip_context_files: bool = False,
|
|
225
|
+
agent_config: AgentConfig | None = None,
|
|
226
|
+
sandbox_config: SandboxConfig | None = None,
|
|
227
|
+
cutoffs: dict[str, str] | None = None,
|
|
228
|
+
on_result: Callable[[RunResult], None] | None = None,
|
|
229
|
+
on_start: Callable[[Task, ModelSpec], None] | None = None,
|
|
230
|
+
on_step: Callable[[int, str], None] | None = None,
|
|
231
|
+
) -> tuple[list[RunResult], str | None]:
|
|
232
|
+
"""Run every (task, model) pair sequentially. Resumable: pairs already in results.jsonl are skipped.
|
|
233
|
+
|
|
234
|
+
Returns (results, abort_message). abort_message is set when the budget stopped the run.
|
|
235
|
+
"""
|
|
236
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
(out_dir / "trajectories").mkdir(exist_ok=True)
|
|
238
|
+
results_path = out_dir / "results.jsonl"
|
|
239
|
+
done: dict[tuple[str, str], RunResult] = {}
|
|
240
|
+
if results_path.exists():
|
|
241
|
+
for line in results_path.read_text().splitlines():
|
|
242
|
+
if line.strip():
|
|
243
|
+
r = RunResult.from_dict(json.loads(line))
|
|
244
|
+
done[(r.task_id, r.model)] = r
|
|
245
|
+
meta_path = out_dir / "run.json"
|
|
246
|
+
meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
|
|
247
|
+
meta.update(
|
|
248
|
+
{
|
|
249
|
+
"tool": "commitarena",
|
|
250
|
+
"version": __version__,
|
|
251
|
+
"updated_at": datetime.now(UTC).isoformat(timespec="seconds"),
|
|
252
|
+
"hardware": hardware(),
|
|
253
|
+
"models": sorted({*meta.get("models", []), *(m.id for m in models)}),
|
|
254
|
+
"cutoffs": {**meta.get("cutoffs", {}), **(cutoffs or {})},
|
|
255
|
+
"agent": {k: v for k, v in asdict(agent_config or AgentConfig()).items() if k != "system_prompt"},
|
|
256
|
+
"sandbox": asdict(sandbox_config or SandboxConfig()),
|
|
257
|
+
"context_file": context.name if context else None,
|
|
258
|
+
"strip_context_files": strip_context_files,
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
meta.setdefault("created_at", meta["updated_at"])
|
|
262
|
+
meta_path.write_text(json.dumps(meta, indent=1))
|
|
263
|
+
tasks_by_id = {t.id: t for t in tasks}
|
|
264
|
+
existing_tasks = out_dir / "tasks.json"
|
|
265
|
+
if existing_tasks.exists():
|
|
266
|
+
for t in load_tasks(existing_tasks):
|
|
267
|
+
tasks_by_id.setdefault(t.id, t)
|
|
268
|
+
save_tasks(existing_tasks, list(tasks_by_id.values()))
|
|
269
|
+
results = list(done.values())
|
|
270
|
+
abort = None
|
|
271
|
+
for model in models:
|
|
272
|
+
for task in tasks:
|
|
273
|
+
if (task.id, model.id) in done:
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
budget.check()
|
|
277
|
+
except BudgetExceeded as e:
|
|
278
|
+
return results, str(e)
|
|
279
|
+
if on_start:
|
|
280
|
+
on_start(task, model)
|
|
281
|
+
traj: list = []
|
|
282
|
+
res = run_task(
|
|
283
|
+
task, model, budget=budget, context=context, strip_context_files=strip_context_files,
|
|
284
|
+
agent_config=agent_config,
|
|
285
|
+
sandbox_config=sandbox_config, on_step=on_step, keep_trajectory=traj,
|
|
286
|
+
) # fmt: skip
|
|
287
|
+
results.append(res)
|
|
288
|
+
with results_path.open("a") as f:
|
|
289
|
+
f.write(json.dumps(res.to_dict(), ensure_ascii=False) + "\n")
|
|
290
|
+
safe = model.id.replace("/", "_").replace(":", "_")
|
|
291
|
+
(out_dir / "trajectories" / f"{task.id}__{safe}.json").write_text(
|
|
292
|
+
json.dumps(traj, indent=1, ensure_ascii=False)
|
|
293
|
+
)
|
|
294
|
+
if on_result:
|
|
295
|
+
on_result(res)
|
|
296
|
+
if res.stop_reason == "budget_exceeded":
|
|
297
|
+
return results, res.error
|
|
298
|
+
return results, abort
|
commitarena/sandbox.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Docker sandbox: every command runs in a container started with ``--network none``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tarfile
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .errors import DockerUnavailable
|
|
15
|
+
|
|
16
|
+
WORKDIR = "/work"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _docker(
|
|
20
|
+
*args: str, input: bytes | None = None, timeout: float | None = None
|
|
21
|
+
) -> subprocess.CompletedProcess:
|
|
22
|
+
try:
|
|
23
|
+
return subprocess.run(["docker", *args], input=input, capture_output=True, timeout=timeout)
|
|
24
|
+
except FileNotFoundError as e:
|
|
25
|
+
raise DockerUnavailable(
|
|
26
|
+
"Docker is not installed (no 'docker' command on PATH). Install Docker Desktop or Docker Engine."
|
|
27
|
+
) from e
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ensure_docker() -> None:
|
|
31
|
+
"""Raise DockerUnavailable with an actionable message if the daemon is not reachable."""
|
|
32
|
+
try:
|
|
33
|
+
cp = _docker("info", "--format", "{{.ServerVersion}}", timeout=20)
|
|
34
|
+
except subprocess.TimeoutExpired as e:
|
|
35
|
+
raise DockerUnavailable("Docker did not answer within 20 s. Is Docker Desktop starting up?") from e
|
|
36
|
+
if cp.returncode != 0:
|
|
37
|
+
raise DockerUnavailable(
|
|
38
|
+
"Docker is not running (docker info failed). Start Docker Desktop or the docker service and retry."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SandboxConfig:
|
|
44
|
+
cpus: float = 2.0
|
|
45
|
+
memory: str = "2g"
|
|
46
|
+
pids_limit: int = 512
|
|
47
|
+
command_timeout: int = 120 # seconds per command
|
|
48
|
+
network: str = "none"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class EnvSpec:
|
|
53
|
+
"""How to build the per-repo test image (built once, with network; tasks run without)."""
|
|
54
|
+
|
|
55
|
+
python: str = "3.12"
|
|
56
|
+
setup: str = "pip install --no-cache-dir -e ."
|
|
57
|
+
extra_packages: str = "pytest==9.2.0"
|
|
58
|
+
|
|
59
|
+
def dockerfile(self) -> str:
|
|
60
|
+
return (
|
|
61
|
+
f"FROM python:{self.python}-slim\n"
|
|
62
|
+
"RUN apt-get update && apt-get install -y --no-install-recommends git "
|
|
63
|
+
"&& rm -rf /var/lib/apt/lists/*\n"
|
|
64
|
+
f"COPY snapshot/ {WORKDIR}/\n"
|
|
65
|
+
f"WORKDIR {WORKDIR}\n"
|
|
66
|
+
f"RUN pip install --no-cache-dir {self.extra_packages} && {self.setup}\n"
|
|
67
|
+
# Keep installed deps (and the editable .pth pointing at /work); drop the snapshot.
|
|
68
|
+
f"RUN find {WORKDIR} -mindepth 1 -delete\n"
|
|
69
|
+
f"ENV PYTHONPATH={WORKDIR}:{WORKDIR}/src PYTHONDONTWRITEBYTECODE=1 PIP_NO_INDEX=1\n"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def image_tag(self, slug: str) -> str:
|
|
73
|
+
h = hashlib.sha256(self.dockerfile().encode()).hexdigest()[:10]
|
|
74
|
+
return f"commitarena-env-{slug}:{h}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def image_exists(tag: str) -> bool:
|
|
78
|
+
return _docker("image", "inspect", tag).returncode == 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_image(tag: str, env: EnvSpec, snapshot_dir: Path, build_root: Path) -> None:
|
|
82
|
+
"""Build the env image from a snapshot of the repo (network is used only here)."""
|
|
83
|
+
ensure_docker()
|
|
84
|
+
ctx = build_root / f"ctx-{uuid.uuid4().hex[:8]}"
|
|
85
|
+
shutil.copytree(snapshot_dir, ctx / "snapshot", ignore=shutil.ignore_patterns(".git"))
|
|
86
|
+
(ctx / "Dockerfile").write_text(env.dockerfile())
|
|
87
|
+
try:
|
|
88
|
+
cp = _docker("build", "-q", "-t", tag, str(ctx), timeout=1800)
|
|
89
|
+
finally:
|
|
90
|
+
shutil.rmtree(ctx, ignore_errors=True)
|
|
91
|
+
if cp.returncode != 0:
|
|
92
|
+
tail = cp.stderr.decode(errors="replace")[-2000:]
|
|
93
|
+
raise DockerUnavailable(f"Building image {tag} failed:\n{tail}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ExecResult:
|
|
98
|
+
exit_code: int
|
|
99
|
+
output: str
|
|
100
|
+
timed_out: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _tar_dir(src: Path) -> bytes:
|
|
104
|
+
buf = io.BytesIO()
|
|
105
|
+
with tarfile.open(fileobj=buf, mode="w") as tar:
|
|
106
|
+
for p in sorted(src.rglob("*")):
|
|
107
|
+
tar.add(p, arcname=str(p.relative_to(src)), recursive=False)
|
|
108
|
+
return buf.getvalue()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Sandbox:
|
|
112
|
+
"""A long-lived container with no network. Use as a context manager."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, image: str, config: SandboxConfig | None = None):
|
|
115
|
+
self.image = image
|
|
116
|
+
self.config = config or SandboxConfig()
|
|
117
|
+
self.name = f"commitarena-{uuid.uuid4().hex[:12]}"
|
|
118
|
+
self.started = False
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> Sandbox:
|
|
121
|
+
self.start()
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def __exit__(self, *exc) -> None:
|
|
125
|
+
self.stop()
|
|
126
|
+
|
|
127
|
+
def start(self) -> None:
|
|
128
|
+
c = self.config
|
|
129
|
+
cp = _docker(
|
|
130
|
+
"run", "-d", "--rm", "--name", self.name, "--label", "commitarena=sandbox",
|
|
131
|
+
"--network", c.network,
|
|
132
|
+
"--cpus", str(c.cpus), "--memory", c.memory, "--memory-swap", c.memory,
|
|
133
|
+
"--pids-limit", str(c.pids_limit),
|
|
134
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
|
135
|
+
"-w", WORKDIR, self.image, "sleep", "infinity",
|
|
136
|
+
timeout=120,
|
|
137
|
+
) # fmt: skip
|
|
138
|
+
if cp.returncode != 0:
|
|
139
|
+
raise DockerUnavailable(
|
|
140
|
+
f"Could not start sandbox container: {cp.stderr.decode(errors='replace')}"
|
|
141
|
+
)
|
|
142
|
+
self.started = True
|
|
143
|
+
|
|
144
|
+
def stop(self) -> None:
|
|
145
|
+
if self.started:
|
|
146
|
+
_docker("rm", "-f", self.name, timeout=60)
|
|
147
|
+
self.started = False
|
|
148
|
+
|
|
149
|
+
def copy_in(self, src: Path) -> None:
|
|
150
|
+
"""Copy a host directory into /work. Files end up owned by root (no CAP_DAC_OVERRIDE needed)."""
|
|
151
|
+
cp = _docker(
|
|
152
|
+
"exec", "-i", self.name, "tar", "-xf", "-", "-C", WORKDIR, "--no-same-owner",
|
|
153
|
+
input=_tar_dir(src), timeout=300,
|
|
154
|
+
) # fmt: skip
|
|
155
|
+
if cp.returncode != 0:
|
|
156
|
+
raise DockerUnavailable(f"Copy into sandbox failed: {cp.stderr.decode(errors='replace')}")
|
|
157
|
+
|
|
158
|
+
def copy_out(self, dest: Path) -> None:
|
|
159
|
+
cp = _docker("exec", self.name, "tar", "-cf", "-", "-C", WORKDIR, ".", timeout=300)
|
|
160
|
+
if cp.returncode != 0:
|
|
161
|
+
raise DockerUnavailable(f"Copy out of sandbox failed: {cp.stderr.decode(errors='replace')}")
|
|
162
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
with tarfile.open(fileobj=io.BytesIO(cp.stdout)) as tar:
|
|
164
|
+
tar.extractall(dest, filter="data")
|
|
165
|
+
|
|
166
|
+
def exec(self, command: str, timeout: int | None = None) -> ExecResult:
|
|
167
|
+
t = int(timeout or self.config.command_timeout)
|
|
168
|
+
if "\x00" in command:
|
|
169
|
+
return ExecResult(126, "error: the command contains a null byte; remove it and try again")
|
|
170
|
+
try:
|
|
171
|
+
cp = _docker(
|
|
172
|
+
"exec", self.name, "timeout", "-s", "KILL", str(t), "bash", "-c", "exec 2>&1\n" + command,
|
|
173
|
+
timeout=t + 30,
|
|
174
|
+
) # fmt: skip
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
return ExecResult(124, f"[command timed out after {t}s]", True)
|
|
177
|
+
out = cp.stdout.decode(errors="replace") + cp.stderr.decode(errors="replace")
|
|
178
|
+
if cp.returncode in (124, 137) and t:
|
|
179
|
+
return ExecResult(cp.returncode, out + f"\n[command killed: time limit {t}s]", True)
|
|
180
|
+
return ExecResult(cp.returncode, out)
|