benchkit-cli 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.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
benchkit/sandbox.py ADDED
@@ -0,0 +1,1167 @@
1
+ """Persistent Docker environments for unmodified coding-agent harnesses.
2
+
3
+ Builds the images described in :mod:`benchkit.dockerfiles`, runs Pi tasks and
4
+ mc-arena scripts in them, and removes everything a run created.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ import threading
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path, PurePosixPath
21
+ from urllib.parse import urlsplit, urlunsplit
22
+
23
+ from benchkit.client import InferenceClient, _openai_base
24
+ from benchkit.dockerfiles import (
25
+ AIDER_PI_DOCKERFILE,
26
+ GIT_SURGERY_PI_DOCKERFILE,
27
+ PI_DOCKERFILE,
28
+ PatchEvalRuntimeRecipe,
29
+ mc_arena_dockerfile,
30
+ patcheval_dockerfile,
31
+ )
32
+
33
+ # The Aider suite reads its pinned commit from here.
34
+ from benchkit.dockerfiles import AIDER_POLYGLOT_COMMIT as AIDER_POLYGLOT_COMMIT
35
+
36
+ _PI_IMAGE_SESSION = uuid.uuid4().hex[:12]
37
+ PI_IMAGE = f"benchkit-pi:{_PI_IMAGE_SESSION}"
38
+ AIDER_PI_IMAGE = f"benchkit-pi-aider-polyglot:{_PI_IMAGE_SESSION}"
39
+ GIT_SURGERY_PI_IMAGE = f"benchkit-pi-git-surgery:{_PI_IMAGE_SESSION}"
40
+ PI_PACKAGE = "@earendil-works/pi-coding-agent@0.84.2"
41
+ PI_VERSION = "0.84.2"
42
+ _PROXY_SOURCE = Path(__file__).with_name("_pi_proxy.py")
43
+ _ANSWER_KEY_GUARD_SOURCE = Path(__file__).with_name("answer_key_guard.ts")
44
+ _PI_PACKAGE_ROOT = Path(__file__).with_name("pi_package")
45
+ _PATCHEVAL_IMAGE_SESSION = uuid.uuid4().hex[:12]
46
+ _MC_ARENA_IMAGE_SESSION = uuid.uuid4().hex[:12]
47
+ _BUILDKIT_DRIVER_IMAGE = "moby/buildkit:buildx-stable-1"
48
+
49
+ # One label scopes every image, container, network, volume, and build-cache
50
+ # entry that this process creates, so cleanup can remove exactly its own
51
+ # resources instead of pruning Docker globally.
52
+ RUN_ID = uuid.uuid4().hex[:16]
53
+ RUN_LABEL = f"benchkit.run={RUN_ID}"
54
+ MANAGED_LABEL = "benchkit.managed=true"
55
+ _BUILDER = f"benchkit-build-{RUN_ID}"
56
+ _BUILDKIT_CONTAINER = f"buildx_buildkit_{_BUILDER}0"
57
+ _BUILDKIT_VOLUME = f"buildx_buildkit_{_BUILDER}0_state"
58
+ _UV_CACHE_ID = f"benchkit-uv-{RUN_ID}"
59
+ _BUILD_LOCK = threading.Lock()
60
+ _BUILD_STATE: dict[str, object] = {
61
+ "builder": False,
62
+ "driver_was_present": True,
63
+ "pulled": set(),
64
+ }
65
+
66
+
67
+ def resource_labels(owner_id: str | None = None) -> list[str]:
68
+ """Return the label flags every managed Docker resource must carry."""
69
+ labels = ["--label", MANAGED_LABEL, "--label", RUN_LABEL]
70
+ if owner_id is not None:
71
+ labels.extend(["--label", f"benchkit.owner={owner_id}"])
72
+ return labels
73
+
74
+
75
+ class SandboxError(RuntimeError):
76
+ """The Docker-backed agent environment could not be prepared or used."""
77
+
78
+
79
+ def _docker_binary() -> str:
80
+ requested = os.environ.get("BENCHKIT_DOCKER", "docker")
81
+ resolved = shutil.which(requested)
82
+ if resolved is None:
83
+ raise SandboxError(
84
+ "Pi harness requires Docker; install Docker and ensure `docker` is on PATH"
85
+ )
86
+ return resolved
87
+
88
+
89
+ def _tail(text: str, limit: int = 4000) -> str:
90
+ text = text.strip()
91
+ return text[-limit:] if len(text) > limit else text
92
+
93
+
94
+ def _run(
95
+ args: list[str],
96
+ *,
97
+ input_text: str | None = None,
98
+ timeout: float | None = None,
99
+ check: bool = True,
100
+ ) -> subprocess.CompletedProcess[str]:
101
+ try:
102
+ completed = subprocess.run(
103
+ args,
104
+ input=input_text,
105
+ text=True,
106
+ errors="replace",
107
+ capture_output=True,
108
+ timeout=timeout,
109
+ )
110
+ except subprocess.TimeoutExpired as exc:
111
+ raise SandboxError(f"Docker command timed out: {args[1]}") from exc
112
+ except OSError as exc:
113
+ raise SandboxError(f"Could not run Docker: {exc}") from exc
114
+ if check and completed.returncode:
115
+ detail = _tail(completed.stderr or completed.stdout) or "unknown Docker error"
116
+ raise SandboxError(f"Docker {args[1]} failed: {detail}")
117
+ return completed
118
+
119
+
120
+ def _docker_upstream(url: str) -> str:
121
+ """Make a host-local inference URL reachable from the proxy container."""
122
+ parsed = urlsplit(_openai_base(url))
123
+ host = parsed.hostname or ""
124
+ if host not in {"localhost", "127.0.0.1", "::1"}:
125
+ return urlunsplit(parsed)
126
+ replacement = "host.docker.internal"
127
+ if parsed.port:
128
+ replacement += f":{parsed.port}"
129
+ return urlunsplit((parsed.scheme, replacement, parsed.path, parsed.query, ""))
130
+
131
+
132
+ def cleanup_owned_resources(docker: str, owner_id: str) -> None:
133
+ """Remove every container and network belonging to one Pi runner."""
134
+ label = f"benchkit.owner={owner_id}"
135
+ errors: list[str] = []
136
+
137
+ def listed(command: list[str], kind: str) -> list[str]:
138
+ try:
139
+ result = _run(command, timeout=30, check=False)
140
+ except Exception as exc:
141
+ errors.append(f"could not inspect owned {kind}: {exc}")
142
+ return []
143
+ if result.returncode:
144
+ detail = _tail(result.stderr or result.stdout) or "unknown Docker error"
145
+ errors.append(f"could not inspect owned {kind}: {detail}")
146
+ return []
147
+ return result.stdout.split()
148
+
149
+ container_query = [
150
+ docker,
151
+ "ps",
152
+ "--all",
153
+ "--quiet",
154
+ "--filter",
155
+ f"label={label}",
156
+ ]
157
+ network_query = [
158
+ docker,
159
+ "network",
160
+ "ls",
161
+ "--quiet",
162
+ "--filter",
163
+ f"label={label}",
164
+ ]
165
+ containers = listed(container_query, "containers")
166
+ if containers:
167
+ with contextlib.suppress(Exception):
168
+ _run([docker, "rm", "--force", *containers], timeout=30, check=False)
169
+ networks = listed(network_query, "networks")
170
+ if networks:
171
+ with contextlib.suppress(Exception):
172
+ _run([docker, "network", "rm", *networks], timeout=30, check=False)
173
+
174
+ remaining_containers = listed(container_query, "containers after cleanup")
175
+ remaining_networks = listed(network_query, "networks after cleanup")
176
+ if remaining_containers:
177
+ errors.append("owned containers remain: " + ", ".join(remaining_containers))
178
+ if remaining_networks:
179
+ errors.append("owned networks remain: " + ", ".join(remaining_networks))
180
+ if errors:
181
+ raise SandboxError("Pi Docker cleanup failed: " + "; ".join(errors))
182
+
183
+
184
+ def _verify_absent(
185
+ command: list[str], description: str, errors: list[str], *, timeout: int = 30
186
+ ) -> None:
187
+ """Record an error unless an exact Docker resource is confirmed absent."""
188
+ try:
189
+ result = _run(command, timeout=timeout, check=False)
190
+ except Exception as exc:
191
+ errors.append(f"could not verify {description}: {exc}")
192
+ return
193
+ if result.returncode == 0:
194
+ errors.append(f"{description} remains")
195
+
196
+
197
+ @dataclass
198
+ class LatestPiImage:
199
+ """Build a reproducibly pinned Pi image; the engine builds one per task."""
200
+
201
+ docker: str = field(default_factory=_docker_binary)
202
+ image: str = PI_IMAGE
203
+ dockerfile: str = PI_DOCKERFILE
204
+ transient: bool = True
205
+ pids_limit: int = 256
206
+ build_assets: Path | None = None
207
+ build_files: tuple[tuple[Path, str], ...] = ()
208
+ always_cleanup_image: bool = True
209
+ resource_scope: str = "pi"
210
+ answer_key_guard: bool = False
211
+ version: str = ""
212
+ _ready: bool = field(default=False, init=False, repr=False)
213
+ _lock: threading.Lock = field(
214
+ default_factory=threading.Lock, init=False, repr=False
215
+ )
216
+
217
+ def prepare(self) -> str:
218
+ if self._ready:
219
+ return self.version
220
+ with self._lock:
221
+ if self._ready:
222
+ return self.version
223
+ _run(
224
+ [self.docker, "version", "--format", "{{.Server.Version}}"],
225
+ timeout=10,
226
+ )
227
+ with tempfile.TemporaryDirectory(prefix="benchkit-pi-build-") as directory:
228
+ context = Path(directory)
229
+ (context / "Dockerfile").write_text(self.dockerfile, encoding="utf-8")
230
+ (context / "inference_proxy.py").write_text(
231
+ _PROXY_SOURCE.read_text(encoding="utf-8"),
232
+ encoding="utf-8",
233
+ )
234
+ (context / "answer_key_guard.ts").write_text(
235
+ _ANSWER_KEY_GUARD_SOURCE.read_text(encoding="utf-8"),
236
+ encoding="utf-8",
237
+ )
238
+ shutil.copytree(_PI_PACKAGE_ROOT, context / "pi-package")
239
+ if self.build_assets is not None:
240
+ shutil.copytree(self.build_assets, context / "git-surgery")
241
+ allowed = {
242
+ "Dockerfile",
243
+ "inference_proxy.py",
244
+ "answer_key_guard.ts",
245
+ "pi-package",
246
+ "git-surgery",
247
+ }
248
+ for source, destination in self.build_files:
249
+ target = PurePosixPath(destination)
250
+ if (
251
+ target.is_absolute()
252
+ or len(target.parts) != 1
253
+ or target.name in allowed
254
+ or source.is_symlink()
255
+ or not source.is_file()
256
+ ):
257
+ raise SandboxError("invalid explicit Pi build-context file")
258
+ shutil.copyfile(source, context / target.name)
259
+ allowed.add(target.name)
260
+ dockerignore = ["*", *sorted(f"!{item}" for item in allowed)]
261
+ dockerignore.extend(("!pi-package/**", "!git-surgery/**"))
262
+ (context / ".dockerignore").write_text(
263
+ "\n".join(dockerignore) + "\n", encoding="utf-8"
264
+ )
265
+ try:
266
+ _build_with_run_builder(
267
+ self.docker,
268
+ self.image,
269
+ self.dockerfile,
270
+ context,
271
+ )
272
+ smoke = (
273
+ f"benchkit-{self.resource_scope}-smoke-{uuid.uuid4().hex[:16]}"
274
+ )
275
+ try:
276
+ result = _run(
277
+ [
278
+ self.docker,
279
+ "run",
280
+ "--name",
281
+ smoke,
282
+ "--rm",
283
+ *resource_labels(),
284
+ self.image,
285
+ "pi",
286
+ "--version",
287
+ ],
288
+ timeout=30,
289
+ )
290
+ finally:
291
+ with contextlib.suppress(Exception):
292
+ _run(
293
+ [self.docker, "rm", "--force", smoke],
294
+ timeout=30,
295
+ check=False,
296
+ )
297
+ self.version = result.stdout.strip() or "latest"
298
+ if self.version != PI_VERSION:
299
+ raise SandboxError(
300
+ f"Pi image reported {self.version!r}; "
301
+ f"expected {PI_VERSION!r}"
302
+ )
303
+ except BaseException:
304
+ if self.transient:
305
+ with contextlib.suppress(Exception):
306
+ _run(
307
+ [
308
+ self.docker,
309
+ "image",
310
+ "rm",
311
+ "--force",
312
+ self.image,
313
+ ],
314
+ timeout=60,
315
+ check=False,
316
+ )
317
+ self.version = ""
318
+ raise
319
+ self._ready = True
320
+ return self.version
321
+
322
+ def cleanup(self) -> None:
323
+ """Remove the transient Pi image after its benchmark run."""
324
+ if not self.transient or (not self._ready and not self.always_cleanup_image):
325
+ return
326
+ try:
327
+ _run(
328
+ [self.docker, "image", "rm", "--force", self.image],
329
+ timeout=60,
330
+ check=not self.always_cleanup_image,
331
+ )
332
+ if self.always_cleanup_image:
333
+ errors: list[str] = []
334
+ _verify_absent(
335
+ [self.docker, "image", "inspect", self.image],
336
+ f"transient image {self.image}",
337
+ errors,
338
+ )
339
+ if errors:
340
+ raise SandboxError("Pi Docker cleanup failed: " + "; ".join(errors))
341
+ finally:
342
+ self._ready = False
343
+ self.version = ""
344
+
345
+
346
+ def _base_image_refs(dockerfile: str) -> tuple[str, ...]:
347
+ """Return the external images a Dockerfile pulls, ignoring its own stages."""
348
+ stages: set[str] = set()
349
+ refs: list[str] = []
350
+ for line in dockerfile.splitlines():
351
+ match = re.match(r"\s*FROM\s+(\S+)(?:\s+[Aa][Ss]\s+(\S+))?\s*$", line)
352
+ if match is None:
353
+ continue
354
+ reference, alias = match.group(1), match.group(2)
355
+ if reference not in stages and reference not in refs:
356
+ refs.append(reference)
357
+ if alias:
358
+ stages.add(alias)
359
+ return tuple(refs)
360
+
361
+
362
+ def _run_builder(docker: str) -> str:
363
+ """Create this run's single private Buildx builder on first use.
364
+
365
+ Every build in the run shares one builder, so layers and cache mounts are
366
+ reused instead of rebuilt per task. The builder keeps its BuildKit state in
367
+ its own container and volume, which cleanup removes by exact name. That is
368
+ what keeps a run's build cache separable from the host's own cache without
369
+ ever running a global prune.
370
+ """
371
+ with _BUILD_LOCK:
372
+ if not _BUILD_STATE["builder"]:
373
+ _BUILD_STATE["driver_was_present"] = bool(
374
+ _run(
375
+ [
376
+ docker,
377
+ "image",
378
+ "inspect",
379
+ "--format",
380
+ "{{.Id}}",
381
+ _BUILDKIT_DRIVER_IMAGE,
382
+ ],
383
+ timeout=30,
384
+ check=False,
385
+ ).stdout.strip()
386
+ )
387
+ _run(
388
+ [
389
+ docker,
390
+ "buildx",
391
+ "create",
392
+ "--name",
393
+ _BUILDER,
394
+ "--driver",
395
+ "docker-container",
396
+ "--driver-opt",
397
+ f"image={_BUILDKIT_DRIVER_IMAGE}",
398
+ ],
399
+ timeout=60,
400
+ )
401
+ _BUILD_STATE["builder"] = True
402
+ return _BUILDER
403
+
404
+
405
+ def _build_with_run_builder(
406
+ docker: str,
407
+ image: str,
408
+ dockerfile: str,
409
+ context: Path,
410
+ ) -> None:
411
+ """Build one image on the run's shared builder, pulling bases once."""
412
+ builder = _run_builder(docker)
413
+ with _BUILD_LOCK:
414
+ pulled = _BUILD_STATE["pulled"]
415
+ assert isinstance(pulled, set)
416
+ fresh = [
417
+ reference
418
+ for reference in _base_image_refs(dockerfile)
419
+ if reference not in pulled
420
+ ]
421
+ pulled.update(fresh)
422
+ _run(
423
+ [docker, "image", "rm", "--force", image],
424
+ timeout=60,
425
+ check=False,
426
+ )
427
+ build = [docker, "buildx", "build", "--builder", builder]
428
+ if fresh:
429
+ # Recipe and harness tags are pinned, so one pull per run is enough.
430
+ build.append("--pull")
431
+ build.extend(
432
+ [
433
+ "--label",
434
+ MANAGED_LABEL,
435
+ "--label",
436
+ RUN_LABEL,
437
+ "--load",
438
+ "--tag",
439
+ image,
440
+ str(context),
441
+ ]
442
+ )
443
+ _run(build, timeout=1800)
444
+
445
+
446
+ def _remove_labelled(docker: str, errors: list[str]) -> None:
447
+ """Remove every container, network, volume, and image of this run."""
448
+ queries = (
449
+ (
450
+ "containers",
451
+ [docker, "ps", "--all", "--quiet", "--filter", f"label={RUN_LABEL}"],
452
+ lambda ids: [docker, "rm", "--force", *ids],
453
+ ),
454
+ (
455
+ "networks",
456
+ [docker, "network", "ls", "--quiet", "--filter", f"label={RUN_LABEL}"],
457
+ lambda ids: [docker, "network", "rm", *ids],
458
+ ),
459
+ (
460
+ "volumes",
461
+ [docker, "volume", "ls", "--quiet", "--filter", f"label={RUN_LABEL}"],
462
+ lambda ids: [docker, "volume", "rm", "--force", *ids],
463
+ ),
464
+ (
465
+ "images",
466
+ [docker, "image", "ls", "--quiet", "--filter", f"label={RUN_LABEL}"],
467
+ lambda ids: [docker, "image", "rm", "--force", *ids],
468
+ ),
469
+ )
470
+ for kind, query, removal in queries:
471
+ try:
472
+ listed = _run(query, timeout=30, check=False)
473
+ except Exception as exc:
474
+ errors.append(f"could not inspect run {kind}: {exc}")
475
+ continue
476
+ if listed.returncode:
477
+ detail = _tail(listed.stderr or listed.stdout) or "unknown Docker error"
478
+ errors.append(f"could not inspect run {kind}: {detail}")
479
+ continue
480
+ identifiers = sorted(set(listed.stdout.split()))
481
+ if not identifiers:
482
+ continue
483
+ with contextlib.suppress(Exception):
484
+ _run(removal(identifiers), timeout=120, check=False)
485
+ remaining = _run(query, timeout=30, check=False)
486
+ if remaining.returncode == 0 and remaining.stdout.split():
487
+ errors.append(
488
+ f"run {kind} remain: "
489
+ + ", ".join(sorted(set(remaining.stdout.split())))
490
+ )
491
+
492
+
493
+ def cleanup_run_resources(docker: str | None = None) -> None:
494
+ """Tear down every Docker resource this run created, and nothing else."""
495
+ global _MC_ARENA_READY
496
+ docker = docker or _docker_binary()
497
+ errors: list[str] = []
498
+ # The mc-arena image carries this run's label, so the cleanup below deletes
499
+ # it. Forget that it was built, or a later job in the same process hands
500
+ # `docker run` a tag that no longer exists and every task is a harness
501
+ # error.
502
+ with _MC_ARENA_LOCK:
503
+ _MC_ARENA_READY = False
504
+ with _BUILD_LOCK:
505
+ builder_created = bool(_BUILD_STATE["builder"])
506
+ driver_was_present = bool(_BUILD_STATE["driver_was_present"])
507
+ _BUILD_STATE["builder"] = False
508
+ pulled = _BUILD_STATE["pulled"]
509
+ assert isinstance(pulled, set)
510
+ pulled.clear()
511
+ if builder_created:
512
+ # Removing the builder drops its BuildKit state volume, and with it
513
+ # every layer and uv cache entry the run created.
514
+ cleanup_commands = (
515
+ ([docker, "buildx", "rm", "--force", _BUILDER], 60),
516
+ ([docker, "rm", "--force", _BUILDKIT_CONTAINER], 30),
517
+ ([docker, "volume", "rm", "--force", _BUILDKIT_VOLUME], 30),
518
+ )
519
+ for command, timeout in cleanup_commands:
520
+ with contextlib.suppress(Exception):
521
+ _run(command, timeout=timeout, check=False)
522
+ if not driver_was_present:
523
+ with contextlib.suppress(Exception):
524
+ _run(
525
+ [docker, "image", "rm", "--force", _BUILDKIT_DRIVER_IMAGE],
526
+ timeout=60,
527
+ check=False,
528
+ )
529
+ _remove_labelled(docker, errors)
530
+ if builder_created:
531
+ _verify_absent(
532
+ [docker, "buildx", "inspect", _BUILDER],
533
+ f"Buildx builder {_BUILDER}",
534
+ errors,
535
+ )
536
+ _verify_absent(
537
+ [docker, "container", "inspect", _BUILDKIT_CONTAINER],
538
+ f"BuildKit container {_BUILDKIT_CONTAINER}",
539
+ errors,
540
+ )
541
+ _verify_absent(
542
+ [docker, "volume", "inspect", _BUILDKIT_VOLUME],
543
+ f"BuildKit volume {_BUILDKIT_VOLUME}",
544
+ errors,
545
+ )
546
+ if not driver_was_present:
547
+ _verify_absent(
548
+ [docker, "image", "inspect", _BUILDKIT_DRIVER_IMAGE],
549
+ f"BuildKit driver image {_BUILDKIT_DRIVER_IMAGE}",
550
+ errors,
551
+ )
552
+ if errors:
553
+ raise SandboxError("Pi Docker cleanup failed: " + "; ".join(errors))
554
+
555
+
556
+ MC_ARENA_IMAGE = f"benchkit-mc-arena:{_MC_ARENA_IMAGE_SESSION}"
557
+ # Enough to diagnose a traceback; a script cannot buy more by printing more.
558
+ STDERR_LIMIT = 256 * 1024
559
+
560
+
561
+ _MC_ARENA_READY = False
562
+ _MC_ARENA_USERS = 0
563
+ _MC_ARENA_LOCK = threading.Lock()
564
+
565
+
566
+ def run_resources_created() -> bool:
567
+ """Whether this process built anything the run-level sweep must remove."""
568
+ with _BUILD_LOCK:
569
+ return bool(_BUILD_STATE["builder"])
570
+
571
+
572
+ @contextlib.contextmanager
573
+ def _mc_arena_image_lease():
574
+ """Hold the mc-arena image for one script; the last holder removes it.
575
+
576
+ Each task builds the image (a cache hit on the run's builder), runs its
577
+ script, and deletes it, so a run never keeps the image between tasks.
578
+ """
579
+ global _MC_ARENA_USERS, _MC_ARENA_READY
580
+ with _MC_ARENA_LOCK:
581
+ _MC_ARENA_USERS += 1
582
+ try:
583
+ yield mc_arena_image()
584
+ finally:
585
+ with _MC_ARENA_LOCK:
586
+ _MC_ARENA_USERS -= 1
587
+ release = (
588
+ _MC_ARENA_USERS == 0
589
+ and _MC_ARENA_READY
590
+ and not os.environ.get("BENCHKIT_MC_ARENA_IMAGE", "").strip()
591
+ )
592
+ if release:
593
+ _MC_ARENA_READY = False
594
+ with contextlib.suppress(Exception):
595
+ _run(
596
+ [_docker_binary(), "image", "rm", "--force", MC_ARENA_IMAGE],
597
+ timeout=60,
598
+ check=False,
599
+ )
600
+
601
+
602
+ def mc_arena_image() -> str:
603
+ """Build the mc-arena script runner once per process and return its tag.
604
+
605
+ ``BENCHKIT_MC_ARENA_IMAGE`` names an already-built image instead, for hosts
606
+ that cannot build one (no registry route, no build network). It has to
607
+ provide ``uv`` on PATH and a writable ``/work``.
608
+ """
609
+ prebuilt = os.environ.get("BENCHKIT_MC_ARENA_IMAGE", "").strip()
610
+ if prebuilt:
611
+ return prebuilt
612
+
613
+ global _MC_ARENA_READY
614
+ if _MC_ARENA_READY:
615
+ return MC_ARENA_IMAGE
616
+ docker = _docker_binary()
617
+ with _MC_ARENA_LOCK:
618
+ if _MC_ARENA_READY:
619
+ return MC_ARENA_IMAGE
620
+ _run([docker, "version", "--format", "{{.Server.Version}}"], timeout=10)
621
+ dockerfile = mc_arena_dockerfile()
622
+ with tempfile.TemporaryDirectory(prefix="benchkit-mc-arena-build-") as name:
623
+ context = Path(name)
624
+ (context / "Dockerfile").write_text(dockerfile, encoding="utf-8")
625
+ _build_with_run_builder(docker, MC_ARENA_IMAGE, dockerfile, context)
626
+ _MC_ARENA_READY = True
627
+ return MC_ARENA_IMAGE
628
+
629
+
630
+ @dataclass
631
+ class _CappedStream:
632
+ """Drain one pipe in a thread, keeping at most ``limit`` characters.
633
+
634
+ Truncating after the fact does not help against a script that prints
635
+ forever: the whole stream would already be sitting in this process's
636
+ memory, where the container's own memory limit does not reach. Everything
637
+ past the cap is still read and dropped, so the writer never blocks on a
638
+ full pipe while its deadline runs out.
639
+ """
640
+
641
+ stream: object
642
+ limit: int
643
+ chunks: list[str] = field(default_factory=list)
644
+ kept: int = 0
645
+ truncated: bool = False
646
+
647
+ def pump(self) -> None:
648
+ try:
649
+ while True:
650
+ chunk = self.stream.read(65536)
651
+ if not chunk:
652
+ break
653
+ if self.kept >= self.limit:
654
+ self.truncated = True
655
+ continue
656
+ keep = chunk[: self.limit - self.kept]
657
+ self.chunks.append(keep)
658
+ self.kept += len(keep)
659
+ if len(keep) < len(chunk):
660
+ self.truncated = True
661
+ except (OSError, ValueError): # pragma: no cover - pipe torn down
662
+ pass
663
+ finally:
664
+ with contextlib.suppress(Exception):
665
+ self.stream.close()
666
+
667
+ @property
668
+ def text(self) -> str:
669
+ return "".join(self.chunks)
670
+
671
+
672
+ def _run_capped(
673
+ args: list[str],
674
+ *,
675
+ timeout: float,
676
+ stdout_limit: int,
677
+ stderr_limit: int,
678
+ ) -> tuple[int, _CappedStream, _CappedStream]:
679
+ """Run a command, keeping only bounded prefixes of its two streams."""
680
+ try:
681
+ process = subprocess.Popen(
682
+ args,
683
+ stdout=subprocess.PIPE,
684
+ stderr=subprocess.PIPE,
685
+ text=True,
686
+ errors="replace",
687
+ )
688
+ except OSError as exc:
689
+ raise SandboxError(f"Could not run Docker: {exc}") from exc
690
+
691
+ out = _CappedStream(process.stdout, stdout_limit)
692
+ err = _CappedStream(process.stderr, stderr_limit)
693
+ pumps = [threading.Thread(target=stream.pump, daemon=True) for stream in (out, err)]
694
+ for pump in pumps:
695
+ pump.start()
696
+ try:
697
+ process.wait(timeout=timeout)
698
+ except subprocess.TimeoutExpired:
699
+ process.kill()
700
+ process.wait()
701
+ for pump in pumps:
702
+ pump.join(timeout=10)
703
+ return process.returncode, out, err
704
+
705
+
706
+ @dataclass(frozen=True)
707
+ class ScriptRun:
708
+ """One execution of a model-written script inside the sandbox."""
709
+
710
+ exit_code: int
711
+ stdout: str
712
+ stderr: str
713
+ timed_out: bool = False
714
+ # Set when the script printed more than the sandbox was willing to keep.
715
+ stdout_truncated: bool = False
716
+
717
+ @property
718
+ def ok(self) -> bool:
719
+ return self.exit_code == 0 and not self.timed_out
720
+
721
+
722
+ def run_python_script(
723
+ code: str,
724
+ *,
725
+ timeout_s: float = 30.0,
726
+ stdout_limit: int = 8 * 1024 * 1024,
727
+ ) -> ScriptRun:
728
+ """Run one untrusted Python script in a throwaway, network-less container.
729
+
730
+ The container has no network, no capabilities and a read-only root
731
+ filesystem with small writable tmpfs mounts, plus hard memory, process and
732
+ time limits: a script that forks, allocates or spins is bounded by the
733
+ sandbox rather than by anything the script itself agrees to.
734
+ """
735
+ with _mc_arena_image_lease() as image:
736
+ return _run_python_script_in(
737
+ image, code, timeout_s=timeout_s, stdout_limit=stdout_limit
738
+ )
739
+
740
+
741
+ def _run_python_script_in(
742
+ image: str,
743
+ code: str,
744
+ *,
745
+ timeout_s: float,
746
+ stdout_limit: int,
747
+ ) -> ScriptRun:
748
+ docker = _docker_binary()
749
+ container = f"benchkit-mc-arena-{uuid.uuid4().hex[:12]}"
750
+ memory = os.environ.get("BENCHKIT_MC_ARENA_MEMORY", "512m")
751
+ cpus = os.environ.get("BENCHKIT_MC_ARENA_CPUS", "1")
752
+ pids = os.environ.get("BENCHKIT_MC_ARENA_PIDS", "64")
753
+
754
+ try:
755
+ _run(
756
+ [
757
+ docker,
758
+ "run",
759
+ "--detach",
760
+ "--name",
761
+ container,
762
+ *resource_labels(),
763
+ "--network",
764
+ "none",
765
+ "--cap-drop",
766
+ "ALL",
767
+ "--security-opt",
768
+ "no-new-privileges",
769
+ "--read-only",
770
+ "--tmpfs",
771
+ "/tmp:size=64m",
772
+ "--tmpfs",
773
+ "/home/runner:size=64m,uid=1000,gid=1000",
774
+ "--tmpfs",
775
+ "/work:size=16m,uid=1000,gid=1000",
776
+ "--pids-limit",
777
+ pids,
778
+ "--memory",
779
+ memory,
780
+ "--cpus",
781
+ cpus,
782
+ image,
783
+ "sleep",
784
+ "infinity",
785
+ ],
786
+ timeout=120,
787
+ )
788
+ # `docker cp` refuses a read-only rootfs even when the destination is a
789
+ # tmpfs, so the script is written from inside the container instead.
790
+ _run(
791
+ [docker, "exec", "--interactive", container, "tee", "/work/build.py"],
792
+ input_text=code,
793
+ timeout=60,
794
+ )
795
+ # Read the streams under a hard cap rather than buffering whatever the
796
+ # script decides to print: the container's memory limit bounds the
797
+ # script, not this process's copy of its output.
798
+ code_out, out, err = _run_capped(
799
+ [
800
+ docker,
801
+ "exec",
802
+ "--workdir",
803
+ "/work",
804
+ container,
805
+ "timeout",
806
+ "--signal=KILL",
807
+ f"{timeout_s:g}s",
808
+ "uv",
809
+ "run",
810
+ "--no-project",
811
+ "--offline",
812
+ "build.py",
813
+ ],
814
+ timeout=timeout_s + 60,
815
+ stdout_limit=stdout_limit,
816
+ stderr_limit=STDERR_LIMIT,
817
+ )
818
+ finally:
819
+ with contextlib.suppress(Exception):
820
+ _run([docker, "rm", "--force", container], timeout=60, check=False)
821
+
822
+ return ScriptRun(
823
+ exit_code=code_out,
824
+ stdout=out.text,
825
+ stdout_truncated=out.truncated,
826
+ stderr=_tail(err.text, 8000),
827
+ # `timeout` reports 137 for the KILL it sends once the deadline passes.
828
+ timed_out=code_out == 137,
829
+ )
830
+
831
+
832
+ def aider_pi_image() -> LatestPiImage:
833
+ """Return an image definition containing every Aider Polyglot toolchain."""
834
+ return LatestPiImage(
835
+ image=AIDER_PI_IMAGE,
836
+ dockerfile=AIDER_PI_DOCKERFILE,
837
+ transient=True,
838
+ # cpp/bank-account creates 1,000 simultaneous std::threads. Linux
839
+ # accounts threads against Docker's PID cgroup, so the generic Pi
840
+ # sandbox limit of 256 makes the official test suite impossible.
841
+ pids_limit=2048,
842
+ answer_key_guard=True,
843
+ resource_scope="aider-polyglot",
844
+ )
845
+
846
+
847
+ def git_surgery_pi_image() -> LatestPiImage:
848
+ """Return the stock Pi image with pinned Git and Git Surgery assets."""
849
+ return LatestPiImage(
850
+ image=GIT_SURGERY_PI_IMAGE,
851
+ dockerfile=GIT_SURGERY_PI_DOCKERFILE,
852
+ transient=True,
853
+ build_assets=Path(__file__).with_name("git_surgery"),
854
+ resource_scope="git-surgery",
855
+ )
856
+
857
+
858
+ def _regular_file_sha256(path: Path) -> str:
859
+ digest = hashlib.sha256()
860
+ with path.open("rb") as handle:
861
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
862
+ digest.update(block)
863
+ return digest.hexdigest()
864
+
865
+
866
+ def _tree_sha256(root: Path) -> str:
867
+ digest = hashlib.sha256()
868
+ for path in sorted(root.rglob("*")):
869
+ if path.is_symlink():
870
+ raise ValueError("Pi package build assets must not contain symlinks")
871
+ if not path.is_file():
872
+ continue
873
+ relative = path.relative_to(root).as_posix().encode()
874
+ digest.update(len(relative).to_bytes(8, "big"))
875
+ digest.update(relative)
876
+ digest.update(bytes.fromhex(_regular_file_sha256(path)))
877
+ return digest.hexdigest()
878
+
879
+
880
+ def patcheval_pi_image(
881
+ source_archive: Path,
882
+ source_sha256: str,
883
+ recipe: PatchEvalRuntimeRecipe,
884
+ ) -> LatestPiImage:
885
+ """Build one task runtime locally without retaining buildkit state."""
886
+ if source_archive.is_symlink() or not source_archive.is_file():
887
+ raise ValueError("PatchEval source archive must be a regular file")
888
+ if not re.fullmatch(r"[0-9a-f]{64}", source_sha256):
889
+ raise ValueError("PatchEval source_sha256 must be a lowercase SHA-256")
890
+ if _regular_file_sha256(source_archive) != source_sha256:
891
+ raise ValueError("PatchEval source archive checksum mismatch")
892
+ # Hash a run-independent Dockerfile so the same task keeps one identity
893
+ # across runs even though the uv cache mount is run-scoped.
894
+ dockerfile = patcheval_dockerfile(recipe, _UV_CACHE_ID)
895
+ identity_dockerfile = patcheval_dockerfile(recipe, "benchkit-uv")
896
+ recipe_json = json.dumps(
897
+ {
898
+ "schema_version": recipe.schema_version,
899
+ "base_image": recipe.base_image,
900
+ "sync_command": list(recipe.sync_command),
901
+ "bootstrap_command": list(recipe.bootstrap_command),
902
+ "environment": list(recipe.environment),
903
+ },
904
+ sort_keys=True,
905
+ separators=(",", ":"),
906
+ )
907
+ identity = hashlib.sha256()
908
+ for value in (
909
+ identity_dockerfile,
910
+ source_sha256,
911
+ recipe_json,
912
+ _tree_sha256(_PI_PACKAGE_ROOT),
913
+ _regular_file_sha256(_PROXY_SOURCE),
914
+ _regular_file_sha256(_ANSWER_KEY_GUARD_SOURCE),
915
+ ):
916
+ identity.update(value.encode())
917
+ identity.update(b"\0")
918
+ image_id = identity.hexdigest()[:16]
919
+ return LatestPiImage(
920
+ image=f"benchkit-pi-patcheval:{_PATCHEVAL_IMAGE_SESSION}-{image_id}",
921
+ dockerfile=dockerfile,
922
+ transient=True,
923
+ build_files=((source_archive, "parent-source.tar"),),
924
+ always_cleanup_image=True,
925
+ resource_scope="patcheval",
926
+ )
927
+
928
+
929
+ @dataclass
930
+ class DockerTaskEnvironment:
931
+ """One isolated, persistent workspace shared by every native Pi tool call."""
932
+
933
+ client: InferenceClient
934
+ model: str
935
+ image: LatestPiImage
936
+ docker: str = field(default_factory=_docker_binary)
937
+ owner_id: str = field(default_factory=lambda: uuid.uuid4().hex)
938
+ workdir: str = "/workspace"
939
+ task_name: str = field(default_factory=lambda: f"benchkit-{uuid.uuid4().hex[:12]}")
940
+ network_name: str = field(init=False)
941
+ proxy_name: str = field(init=False)
942
+ container_name: str = field(init=False)
943
+ _started: bool = field(default=False, init=False, repr=False)
944
+
945
+ def __post_init__(self) -> None:
946
+ self.network_name = f"{self.task_name}-net"
947
+ self.proxy_name = f"{self.task_name}-proxy"
948
+ self.container_name = f"{self.task_name}-agent"
949
+
950
+ def __enter__(self) -> DockerTaskEnvironment:
951
+ self.start()
952
+ return self
953
+
954
+ def __exit__(self, *_args: object) -> None:
955
+ self.stop()
956
+
957
+ def start(self) -> None:
958
+ if self._started:
959
+ return
960
+ if not self.image.version:
961
+ raise SandboxError("Pi image must be prepared before starting a task")
962
+ try:
963
+ labels = resource_labels(self.owner_id)
964
+ _run(
965
+ [
966
+ self.docker,
967
+ "network",
968
+ "create",
969
+ "--internal",
970
+ *labels,
971
+ self.network_name,
972
+ ]
973
+ )
974
+ proxy_args = [
975
+ self.docker,
976
+ "run",
977
+ "--detach",
978
+ "--name",
979
+ self.proxy_name,
980
+ *labels,
981
+ "--network",
982
+ self.network_name,
983
+ "--network-alias",
984
+ "inference",
985
+ "--add-host",
986
+ "host.docker.internal:host-gateway",
987
+ "--cap-drop",
988
+ "ALL",
989
+ "--security-opt",
990
+ "no-new-privileges",
991
+ "--pids-limit",
992
+ "64",
993
+ "--memory",
994
+ "256m",
995
+ "--env",
996
+ f"BENCHKIT_UPSTREAM={_docker_upstream(self.client.host)}",
997
+ "--env",
998
+ f"BENCHKIT_MODEL={self.model}",
999
+ "--env",
1000
+ f"BENCHKIT_UPSTREAM_TIMEOUT={self.client.timeout}",
1001
+ ]
1002
+ if self.client.api_key:
1003
+ proxy_args.extend(
1004
+ ["--env", f"BENCHKIT_UPSTREAM_API_KEY={self.client.api_key}"]
1005
+ )
1006
+ proxy_args.extend(
1007
+ [self.image.image, "python3", "/opt/benchkit/inference_proxy.py"]
1008
+ )
1009
+ _run(proxy_args)
1010
+ _run([self.docker, "network", "connect", "bridge", self.proxy_name])
1011
+
1012
+ memory = os.environ.get("BENCHKIT_SANDBOX_MEMORY", "2g")
1013
+ cpus = os.environ.get("BENCHKIT_SANDBOX_CPUS", "2")
1014
+ pids = os.environ.get("BENCHKIT_SANDBOX_PIDS", str(self.image.pids_limit))
1015
+ _run(
1016
+ [
1017
+ self.docker,
1018
+ "run",
1019
+ "--detach",
1020
+ "--name",
1021
+ self.container_name,
1022
+ *labels,
1023
+ "--network",
1024
+ self.network_name,
1025
+ "--cap-drop",
1026
+ "ALL",
1027
+ "--security-opt",
1028
+ "no-new-privileges",
1029
+ "--pids-limit",
1030
+ pids,
1031
+ "--memory",
1032
+ memory,
1033
+ "--cpus",
1034
+ cpus,
1035
+ self.image.image,
1036
+ ]
1037
+ )
1038
+ self._write_model_config()
1039
+ self._started = True
1040
+ except Exception:
1041
+ self.stop()
1042
+ raise
1043
+
1044
+ def _write_model_config(self) -> None:
1045
+ context_length = None
1046
+ discover = getattr(self.client, "context_length", None)
1047
+ if callable(discover):
1048
+ context_length = discover(self.model)
1049
+ model: dict[str, object] = {"id": self.model, "name": self.model}
1050
+ if isinstance(context_length, int) and context_length > 0:
1051
+ model["contextWindow"] = context_length
1052
+
1053
+ provider: dict[str, object] = {
1054
+ "baseUrl": "http://inference:8080/v1",
1055
+ "api": "openai-completions",
1056
+ "apiKey": "benchkit-sandbox",
1057
+ "models": [model],
1058
+ }
1059
+ if self.client.provider == "ollama":
1060
+ provider["compat"] = {
1061
+ "supportsDeveloperRole": False,
1062
+ "supportsReasoningEffort": False,
1063
+ }
1064
+ payload = json.dumps({"providers": {"benchkit": provider}})
1065
+ _run(
1066
+ [
1067
+ self.docker,
1068
+ "exec",
1069
+ "--interactive",
1070
+ self.container_name,
1071
+ "tee",
1072
+ "/home/node/.pi/agent/models.json",
1073
+ ],
1074
+ input_text=payload,
1075
+ )
1076
+
1077
+ def start_pi(self) -> subprocess.Popen[str]:
1078
+ if not self._started:
1079
+ raise SandboxError("task environment is not running")
1080
+ command = [
1081
+ self.docker,
1082
+ "exec",
1083
+ "--interactive",
1084
+ "--workdir",
1085
+ self.workdir,
1086
+ self.container_name,
1087
+ "pi",
1088
+ "--mode",
1089
+ "rpc",
1090
+ "--no-session",
1091
+ "--provider",
1092
+ "benchkit",
1093
+ "--model",
1094
+ self.model,
1095
+ ]
1096
+ if self.image.answer_key_guard:
1097
+ command.extend(["--extension", "/opt/benchkit/answer_key_guard.ts"])
1098
+ return subprocess.Popen(
1099
+ command,
1100
+ stdin=subprocess.PIPE,
1101
+ stdout=subprocess.PIPE,
1102
+ stderr=subprocess.PIPE,
1103
+ text=True,
1104
+ bufsize=1,
1105
+ )
1106
+
1107
+ def pi_scaffold(self) -> dict:
1108
+ """Read scaffold metadata captured from Pi's first inference request."""
1109
+ result = _run(
1110
+ [
1111
+ self.docker,
1112
+ "exec",
1113
+ self.proxy_name,
1114
+ "cat",
1115
+ "/tmp/benchkit_pi_scaffold.json",
1116
+ ],
1117
+ timeout=10,
1118
+ check=False,
1119
+ )
1120
+ if result.returncode:
1121
+ return {}
1122
+ try:
1123
+ payload = json.loads(result.stdout)
1124
+ except (json.JSONDecodeError, TypeError):
1125
+ return {}
1126
+ return payload if isinstance(payload, dict) else {}
1127
+
1128
+ def exec(
1129
+ self,
1130
+ command: list[str],
1131
+ *,
1132
+ workdir: str | None = None,
1133
+ input_text: str | None = None,
1134
+ timeout: float | None = None,
1135
+ check: bool = True,
1136
+ ) -> subprocess.CompletedProcess[str]:
1137
+ return _run(
1138
+ [
1139
+ self.docker,
1140
+ "exec",
1141
+ *(["--workdir", workdir] if workdir is not None else []),
1142
+ self.container_name,
1143
+ *command,
1144
+ ],
1145
+ input_text=input_text,
1146
+ timeout=timeout,
1147
+ check=check,
1148
+ )
1149
+
1150
+ def upload(self, source: Path, destination: str) -> None:
1151
+ _run([self.docker, "cp", str(source), f"{self.container_name}:{destination}"])
1152
+
1153
+ def download(self, source: str, destination: Path) -> None:
1154
+ _run([self.docker, "cp", f"{self.container_name}:{source}", str(destination)])
1155
+
1156
+ def stop(self) -> None:
1157
+ try:
1158
+ commands = [
1159
+ [self.docker, "rm", "--force", self.container_name],
1160
+ [self.docker, "rm", "--force", self.proxy_name],
1161
+ [self.docker, "network", "rm", self.network_name],
1162
+ ]
1163
+ for command in commands:
1164
+ with contextlib.suppress(Exception):
1165
+ _run(command, timeout=30, check=False)
1166
+ finally:
1167
+ self._started = False