coder-eval 0.8.2__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 (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,1253 @@
1
+ """Run a single task inside a fresh Docker container.
2
+
3
+ Host-side counterpart of the in-container ``coder-eval _run-task-internal``
4
+ subcommand. Responsible for: rendering the docker-run argv, bind-mounting task
5
+ inputs and an output dir, streaming container stdout to the host log, and
6
+ reading back ``task.json`` (the only artifact that crosses the boundary).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import json
14
+ import logging
15
+ import os
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import uuid
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, TextIO
24
+
25
+ import yaml
26
+
27
+ from coder_eval.logging_config import DEFAULT_LOG_TAIL_MAX_BYTES
28
+ from coder_eval.models import (
29
+ CONTAINER_INPUT_DIR,
30
+ CONTAINER_OUTPUT_DIR,
31
+ CONTAINER_WORK_DIR,
32
+ RESERVED_CONTAINER_DIRS,
33
+ AgentKind,
34
+ DockerDriverConfig,
35
+ EvaluationResult,
36
+ FinalStatus,
37
+ PreservationMode,
38
+ ResourceLimits,
39
+ )
40
+ from coder_eval.streaming.callbacks import safe_emit
41
+ from coder_eval.streaming.wire import deserialize_event, has_prefix
42
+ from coder_eval.utils import get_default_docker_image_tag
43
+
44
+
45
+ if TYPE_CHECKING:
46
+ from coder_eval.models import ResolvedTask
47
+ from coder_eval.streaming.callbacks import StreamCallback
48
+
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ # Container-side paths (CONTAINER_WORK_DIR/_INPUT_DIR/_OUTPUT_DIR/_TASK_DIR,
54
+ # RESERVED_CONTAINER_DIRS) are imported above from models.container_paths and
55
+ # kept in lockstep with docker/coder_eval_entrypoint.sh.
56
+
57
+ # In-image path of the framework entrypoint, pinned by the host via
58
+ # `docker run --entrypoint` (the image bakes no ENTRYPOINT). MUST equal the
59
+ # `COPY` destination in docker/Dockerfile -- a drift guard test enforces that.
60
+ CONTAINER_ENTRYPOINT = "/usr/local/bin/coder_eval_entrypoint.sh"
61
+
62
+ # Top-level entries under ~/.claude that the per-task RW copy SKIPS. We copy
63
+ # the host's ~/.claude into a throwaway tmp dir and mount that copy read-WRITE
64
+ # so the in-container CLI can write anywhere it needs without ever touching the
65
+ # host's real ~/.claude. The container needs only auth + settings + plugins;
66
+ # everything else under ~/.claude is heavy, transient, or host-local state it
67
+ # never reads, so we drop it to keep the per-task copy cheap. On a real host
68
+ # this is the difference between a ~300 MB copy and a few MB: `security/` (the
69
+ # security plugin's data) alone is often hundreds of MB, and `projects/`
70
+ # (transcripts), `cache/`, `file-history/`, `backups/`, `sessions/`,
71
+ # `telemetry/`, `downloads/`, and `shell-snapshots/` all accumulate without
72
+ # bound. `session-env/` (per-Bash ephemera) is recreated fresh in the copy by
73
+ # the container. The last group is volatile per-session churn the *running* CLI
74
+ # rewrites continuously (this harness itself runs inside Claude Code, so the live
75
+ # host ~/.claude is mutating while we copy): dropping it both keeps the copy lean
76
+ # AND shrinks the window for a mid-walk vanish/rewrite race under --max-parallel
77
+ # (the residual race is covered by the bounded retry in `_copy_claude_home`).
78
+ # Patterns match by basename at every level (shutil.ignore_patterns semantics), so
79
+ # this is a denylist: anything NOT listed here (settings.json, .credentials.json,
80
+ # plugins/) is copied through.
81
+ CLAUDE_COPY_IGNORE = (
82
+ "projects",
83
+ "shell-snapshots",
84
+ "todos",
85
+ "session-env",
86
+ "security",
87
+ "cache",
88
+ "file-history",
89
+ "backups",
90
+ "downloads",
91
+ "sessions",
92
+ "telemetry",
93
+ "history.jsonl",
94
+ "*.lock",
95
+ # Volatile per-session churn rewritten by the live host CLI (race-prone):
96
+ "statsig",
97
+ ".statusline_cache",
98
+ "paste-cache",
99
+ "tasks",
100
+ )
101
+
102
+ # Bounded retries for the lean ~/.claude copy. The live host dir is rewritten by
103
+ # the running CLI while we walk it, so a file can vanish mid-copy and raise; a
104
+ # couple of retries clears the transient case before we give up (see
105
+ # `_copy_claude_home`).
106
+ CLAUDE_COPY_MAX_ATTEMPTS = 3
107
+
108
+ # Host-side heartbeat: the runner touches this file every HEARTBEAT_INTERVAL
109
+ # seconds while alive. The in-container watchdog exits if the file is stale
110
+ # (older than HEARTBEAT_STALE_SECONDS) -- our only defence against the host
111
+ # being SIGKILL'd (e.g. Claude Code's Escape) before the asyncio cleanup
112
+ # runs. Lives in the output dir, which is bind-mounted into the container.
113
+ HEARTBEAT_FILENAME = ".coder_eval_host_heartbeat"
114
+ HEARTBEAT_INTERVAL_SECONDS = 2.0
115
+ HEARTBEAT_STALE_SECONDS = 20
116
+
117
+ # asyncio's StreamReader caps a single line at 64 KiB by default. The
118
+ # container streams stream events as one NDJSON line each (wire.py), and a
119
+ # single event carrying a large tool input -- e.g. an agent Write of a whole
120
+ # .flow/.json file -- serialises well past 64 KiB. The default-limit reader
121
+ # then raises ValueError mid-stream, which tore the container down before it
122
+ # wrote task.json: the entire task was lost and the host recorded a bare
123
+ # ERROR with no per-task report. Give the line reader generous headroom (run()
124
+ # also degrades gracefully past it). Mirrors Orchestrator._POST_RUN_STREAM_LIMIT,
125
+ # the same guard on the orchestrator's post-run subprocesses.
126
+ STDOUT_LINE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
127
+
128
+
129
+ async def _heartbeat_loop(heartbeat_path: Path) -> None:
130
+ """Write a monotonic counter to ``heartbeat_path`` every interval until cancelled.
131
+
132
+ Pair with the in-container watchdog in ``run_task_internal_command``:
133
+ container exits when the counter stops advancing for longer than
134
+ ``HEARTBEAT_STALE_SECONDS``. We write content (not just touch) because
135
+ bind-mount mtime on macOS Docker Desktop's gRPC-FUSE / VirtioFS can
136
+ lag by seconds; a content-encoded counter survives stalled mtime
137
+ semantics. Falls back gracefully if writes start failing.
138
+ """
139
+ counter = 0
140
+ try:
141
+ while True:
142
+ counter += 1
143
+ try:
144
+ await asyncio.to_thread(heartbeat_path.write_text, str(counter), encoding="utf-8")
145
+ except OSError as exc:
146
+ logger.warning("Heartbeat write failed: %s", exc)
147
+ await asyncio.sleep(HEARTBEAT_INTERVAL_SECONDS)
148
+ except asyncio.CancelledError:
149
+ pass
150
+
151
+
152
+ def _preflight() -> None:
153
+ """Verify ``docker`` is on PATH and the daemon is reachable.
154
+
155
+ Cheaper than letting ``docker run`` fail mid-flight: a missing binary
156
+ yields a clear error before we stage inputs or burn the run_dir.
157
+ """
158
+ if shutil.which("docker") is None:
159
+ raise DockerRunError(
160
+ "docker CLI not found on PATH. Install Docker Desktop or set up Docker engine before driver: docker."
161
+ )
162
+ try:
163
+ subprocess.run(
164
+ ["docker", "version", "--format", "{{.Server.Version}}"],
165
+ check=True,
166
+ capture_output=True,
167
+ text=True,
168
+ encoding="utf-8",
169
+ timeout=5,
170
+ )
171
+ except FileNotFoundError as exc:
172
+ # Race: PATH check passed but the binary disappeared before exec.
173
+ raise DockerRunError("docker CLI vanished between PATH check and exec.") from exc
174
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
175
+ raise DockerRunError("docker daemon is not responding. Start Docker Desktop or check `docker info`.") from exc
176
+
177
+
178
+ def _preflight_image_version(image: str) -> None:
179
+ """Assert the image's ``coder_eval`` label matches the host BEFORE running.
180
+
181
+ The PR's original mismatch warning ran *after* ``task.json`` was parsed
182
+ — i.e. after the billed LLM run. The whole point of ``--driver docker``
183
+ is reproducibility; warning post-hoc is the wrong order. Here we inspect
184
+ the image label and warn *before* spawning the container, so a stale
185
+ ``:latest`` doesn't quietly waste a paid run.
186
+
187
+ Missing image / missing label / no-host-version are all soft-fail: log
188
+ and continue (image may have been built before the label was added, or
189
+ coder-eval may be running from a source checkout without a packaged
190
+ version).
191
+ """
192
+ from importlib.metadata import PackageNotFoundError, version
193
+
194
+ try:
195
+ host_version = version("coder-eval")
196
+ except PackageNotFoundError:
197
+ return
198
+ try:
199
+ result = subprocess.run(
200
+ [
201
+ "docker",
202
+ "image",
203
+ "inspect",
204
+ "--format",
205
+ '{{ index .Config.Labels "org.coder-eval.version" }}',
206
+ image,
207
+ ],
208
+ check=True,
209
+ capture_output=True,
210
+ text=True,
211
+ encoding="utf-8",
212
+ timeout=10,
213
+ )
214
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
215
+ # Image absent locally or inspect failed. Let `docker run` raise the
216
+ # canonical error; suppress here so we don't double-fail in argv
217
+ # logging paths that hit this even when the image is fine.
218
+ logger.debug("Pre-flight image inspect failed for %s: %s", image, exc)
219
+ return
220
+ image_version = result.stdout.strip()
221
+ if not image_version or image_version == "unknown":
222
+ logger.warning(
223
+ "Image %s has no org.coder-eval.version label; rebuild with `make docker-image` for pre-flight checks.",
224
+ image,
225
+ )
226
+ return
227
+ if image_version != host_version:
228
+ logger.warning(
229
+ "Image %s coder_eval %s != host %s. Rebuild with `make docker-image` to keep reproducibility.",
230
+ image,
231
+ image_version,
232
+ host_version,
233
+ )
234
+
235
+
236
+ _CONTAINER_NAME_INVALID = re.compile(r"[^a-zA-Z0-9_.-]")
237
+
238
+ # A leading Windows drive letter (``C:\foo`` / ``c:/foo``). Used so the colon
239
+ # in ``C:\foo`` is not misread as the ``src:dst`` separator when a Windows
240
+ # task author writes an extra_mounts entry. Bare ``C:`` (no path body) is
241
+ # intentionally not matched — that is malformed and should fail downstream.
242
+ _DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]")
243
+
244
+
245
+ def _sanitize_container_name_component(s: str) -> str:
246
+ """Strip characters Docker rejects in `--name` so dataset row IDs work.
247
+
248
+ Suite/row tasks have ids like ``suite_id/row_id``; ``/`` is invalid in
249
+ Docker names. Anything outside ``[a-zA-Z0-9_.-]`` collapses to ``_``.
250
+ """
251
+ return _CONTAINER_NAME_INVALID.sub("_", s)
252
+
253
+
254
+ # Destinations that would shadow framework-owned mounts inside the container.
255
+ # Letting a user spec collide with these silently breaks input/output staging.
256
+ # Same reserved set the workspace-dir validator uses (single source of truth in
257
+ # models.container_paths). Extra-mount destinations and WORKDIR both reject these.
258
+ _RESERVED_MOUNT_DESTS = RESERVED_CONTAINER_DIRS
259
+
260
+
261
+ def _validate_extra_mount(spec: str) -> str:
262
+ """Sanity-check a ``-v`` mount spec and return a normalized form.
263
+
264
+ Defends against typos that would silently expose the host fs to the
265
+ container, and against mount specs that shadow framework-owned mounts.
266
+ Normalizes the source side by expanding ``~`` and ``$VAR`` so authors
267
+ can write portable specs. Returns the (possibly rewritten) spec to
268
+ feed back into argv.
269
+
270
+ Notes:
271
+ - Mode is REQUIRED. Forgetting ``:ro`` is the single most common way
272
+ to accidentally hand the container RW access to a host directory,
273
+ so we make the author write it explicitly.
274
+ - Destinations colliding with framework mounts (``/work``, ``/``,
275
+ etc.) are rejected outright.
276
+ """
277
+ # Split off an optional leading Windows drive letter so the colon in
278
+ # ``C:\foo`` is not misread as the ``src:dst`` separator. The container
279
+ # side is always POSIX (Docker containers are Linux), so only the source
280
+ # side can carry a drive letter.
281
+ if _DRIVE_PREFIX.match(spec):
282
+ head, body = spec[:2], spec[2:]
283
+ else:
284
+ head, body = "", spec
285
+ parts = body.split(":")
286
+ if len(parts) < 2 or len(parts) > 3:
287
+ raise ValueError(f"Invalid extra_mounts entry {spec!r}: expected `src:dst[:ro|rw]`.")
288
+ src, dst = head + parts[0], parts[1]
289
+ # Default to read-only when mode is omitted. Mounting host paths RW
290
+ # by default is the wrong sandbox stance: the few RW use-cases are
291
+ # better stated explicitly than implied by silence.
292
+ mode = parts[2] if len(parts) == 3 else "ro"
293
+ if not src:
294
+ raise ValueError(f"Invalid extra_mounts entry {spec!r}: empty source path.")
295
+ if not dst:
296
+ raise ValueError(f"Invalid extra_mounts entry {spec!r}: empty destination path.")
297
+ if not dst.startswith("/"):
298
+ raise ValueError(f"Invalid extra_mounts entry {spec!r}: destination must be an absolute path.")
299
+ if mode not in ("ro", "rw"):
300
+ raise ValueError(f"Invalid extra_mounts entry {spec!r}: mode must be 'ro' or 'rw'.")
301
+ # Expand ~ and $VAR in the source so authors can write portable specs.
302
+ expanded_src = os.path.expandvars(os.path.expanduser(src))
303
+ if not Path(expanded_src).exists():
304
+ raise ValueError(f"Invalid extra_mounts entry {spec!r}: source path does not exist on host.")
305
+ # Reject destinations that shadow framework-owned mounts inside the
306
+ # container. ``/work`` substrings are caught too -- /work/foo would
307
+ # land underneath our staging dir and shadow the input/output tree.
308
+ dst_norm = dst.rstrip("/") or "/"
309
+ if dst_norm in _RESERVED_MOUNT_DESTS or dst_norm.startswith(CONTAINER_WORK_DIR + "/"):
310
+ raise ValueError(
311
+ f"Invalid extra_mounts entry {spec!r}: destination {dst_norm!r} shadows a framework-owned mount."
312
+ )
313
+ return f"{expanded_src}:{dst}:{mode}"
314
+
315
+
316
+ class DockerRunError(RuntimeError):
317
+ """Raised when ``docker run`` exits non-zero AND no task.json was produced.
318
+
319
+ Criterion failures do NOT raise this -- the container always writes
320
+ task.json (with whatever results it has) before exiting, and the host
321
+ parses that regardless of exit code. This is reserved for setup-time
322
+ failures: missing image, daemon down, OOM-kill before the agent started,
323
+ etc.
324
+ """
325
+
326
+
327
+ class DockerBuildError(DockerRunError):
328
+ """Raised when ``docker build`` itself fails (the image never builds).
329
+
330
+ A subclass of :class:`DockerRunError` so existing ``except DockerRunError``
331
+ handlers still catch it, but distinct so the failure is recorded as
332
+ :data:`FinalStatus.BUILD_FAILED` (an environment/setup failure) rather than
333
+ a generic ERROR. Carries the full build log so the runner can persist it to
334
+ ``docker.log`` -- without this, a build failure happens before ``run_dir``
335
+ exists and the task vanishes with no log and no task.json.
336
+ """
337
+
338
+ def __init__(self, message: str, *, build_log: str = "") -> None:
339
+ super().__init__(message)
340
+ self.build_log = build_log
341
+
342
+
343
+ def _assert_workspace_not_reserved(path: str) -> None:
344
+ """Reject a workspace dir that collides with a framework-reserved container path.
345
+
346
+ Defense-in-depth against the same ``RESERVED_CONTAINER_DIRS`` set the
347
+ ``SandboxConfig`` validator uses: a concrete path is already validated at the
348
+ model layer, but an ``"auto"``-detected image WORKDIR (or a directly built
349
+ argv) has not been -- so re-check here before it reaches ``docker run -w``.
350
+ """
351
+ norm = path.rstrip("/") or "/"
352
+ if norm in RESERVED_CONTAINER_DIRS or norm.startswith(CONTAINER_WORK_DIR + "/"):
353
+ raise DockerRunError(
354
+ f"working_dir {path!r} collides with a framework-reserved container path (/, /work, /work/*)."
355
+ )
356
+
357
+
358
+ def _resolve_workspace_dir(cfg_working_dir: str | None, image: str) -> str | None:
359
+ """Resolve the concrete agent workspace path (docker WORKDIR alignment).
360
+
361
+ ``None`` -> ``None`` (feature off). A concrete path -> re-asserted + returned.
362
+ ``"auto"`` -> the image's WORKDIR via ``docker image inspect`` (falling back to
363
+ ``/root`` on an empty / ``"/"`` WORKDIR or any inspect failure -- never crash
364
+ the run over WORKDIR detection, mirroring ``_preflight_image_version``).
365
+ """
366
+ if cfg_working_dir is None:
367
+ return None
368
+ if cfg_working_dir == "auto":
369
+ resolved = "/root"
370
+ try:
371
+ result = subprocess.run(
372
+ ["docker", "image", "inspect", "--format", "{{.Config.WorkingDir}}", image],
373
+ check=True,
374
+ capture_output=True,
375
+ text=True,
376
+ encoding="utf-8",
377
+ timeout=10,
378
+ )
379
+ workdir = result.stdout.strip()
380
+ if workdir and workdir != "/":
381
+ resolved = workdir
382
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
383
+ logger.debug("WORKDIR inspect failed for %s; falling back to /root: %s", image, exc)
384
+ cfg_working_dir = resolved
385
+ _assert_workspace_not_reserved(cfg_working_dir)
386
+ return cfg_working_dir
387
+
388
+
389
+ def _copy_claude_home(host_claude_dir: Path, claude_copy: Path) -> None:
390
+ """Copy the host ``~/.claude`` into ``claude_copy`` with bounded retries.
391
+
392
+ The harness itself runs inside Claude Code, so the *live* host ``~/.claude``
393
+ is actively rewritten (small state JSON, session ephemera) while this walks
394
+ it. Under ``--max-parallel>1`` N tasks copy it concurrently, and a file that
395
+ vanishes or is rewritten mid-walk makes ``shutil.copytree`` raise
396
+ ``FileNotFoundError`` / ``shutil.Error`` (both ``OSError`` subclasses). Left
397
+ uncaught that propagates to ``run_single``'s broad ``except`` and flips an
398
+ otherwise-passing task to ``FinalStatus.ERROR`` — scoring identical agent
399
+ output differently by luck of timing. ``CLAUDE_COPY_IGNORE`` already drops the
400
+ noisiest churn dirs; this retries the residual race a bounded number of times
401
+ (clearing the partial copy between attempts) before giving up. Persistent
402
+ failure still raises — at that point it is a real problem (e.g. perms), and
403
+ the container could not authenticate without ``~/.claude`` anyway.
404
+ """
405
+ last_exc: OSError | None = None
406
+ for attempt in range(1, CLAUDE_COPY_MAX_ATTEMPTS + 1):
407
+ try:
408
+ shutil.copytree(
409
+ host_claude_dir,
410
+ claude_copy,
411
+ ignore=shutil.ignore_patterns(*CLAUDE_COPY_IGNORE),
412
+ # Copy symlinks AS symlinks (do not follow): a plugin marketplace
413
+ # cache can contain a self-referential symlink (e.g. uipath-marketplace
414
+ # `plugins/uipath -> ..`) that makes a symlink-following walk recurse
415
+ # infinitely ("too many levels of symbolic links") and abort the copy.
416
+ # Copying them verbatim is correct and loop-proof. Dangling ones are
417
+ # skipped via ignore_dangling_symlinks.
418
+ symlinks=True,
419
+ ignore_dangling_symlinks=True,
420
+ dirs_exist_ok=True,
421
+ )
422
+ return
423
+ except OSError as exc: # FileNotFoundError / shutil.Error — transient under concurrent host churn
424
+ last_exc = exc
425
+ # Clear the partial tree so the retry (dirs_exist_ok) starts clean.
426
+ shutil.rmtree(claude_copy, ignore_errors=True)
427
+ logger.warning(
428
+ "Copy of host ~/.claude failed (attempt %d/%d), retrying: %s",
429
+ attempt,
430
+ CLAUDE_COPY_MAX_ATTEMPTS,
431
+ exc,
432
+ )
433
+ raise DockerRunError(
434
+ f"Failed to copy host ~/.claude into the container staging dir after {CLAUDE_COPY_MAX_ATTEMPTS} "
435
+ + f"attempts (last error: {last_exc}). The host dir may be churning faster than the copy "
436
+ + "completes, or be unreadable."
437
+ ) from last_exc
438
+
439
+
440
+ class DockerRunner:
441
+ """Spawns a per-task container and reconstructs the EvaluationResult.
442
+
443
+ One instance per task. Stateless across tasks -- batch execution just
444
+ instantiates N runners concurrently.
445
+ """
446
+
447
+ def __init__(
448
+ self,
449
+ rt: ResolvedTask,
450
+ preservation_mode: PreservationMode = PreservationMode.DIRECT_WRITE,
451
+ stream_callback: StreamCallback | None = None,
452
+ verbose: bool = False,
453
+ ) -> None:
454
+ self.rt = rt
455
+ self.preservation_mode = preservation_mode
456
+ self.stream_callback = stream_callback
457
+ self.verbose = verbose
458
+ # Set by _prepare_host_mounts: the tmp lean copy of ~/.claude that
459
+ # _build_argv mounts read-write. None when there is no ~/.claude to
460
+ # forward or the mount is opted out (CODER_EVAL_NO_CLAUDE_MOUNT).
461
+ self._claude_mount_src: Path | None = None
462
+ # Resolved in run() (needs the built image for "auto"). Concrete WORKDIR the
463
+ # agent runs at + copies out from; None = standard artifacts workspace.
464
+ self._workspace_dir: str | None = None
465
+
466
+ @property
467
+ def _docker_config(self) -> DockerDriverConfig:
468
+ return self.rt.task.sandbox.docker
469
+
470
+ @property
471
+ def _limits(self) -> ResourceLimits:
472
+ return self.rt.task.sandbox.limits
473
+
474
+ async def run(self) -> EvaluationResult:
475
+ """Run the task in a container and return the parsed EvaluationResult.
476
+
477
+ The container is responsible for producing ``task.json`` in
478
+ ``CONTAINER_OUTPUT_DIR``. On any path where the container exits
479
+ without producing it, this raises ``DockerRunError`` and the batch
480
+ dispatcher converts that to an ERROR-status EvaluationResult.
481
+ """
482
+ _preflight()
483
+ # Resolve the run image: build from a Dockerfile if configured (which
484
+ # overrides `image`), else use the configured image. The build is
485
+ # side-effecting, so it runs in a worker thread like the other docker
486
+ # calls in this method.
487
+ try:
488
+ image = await asyncio.to_thread(self._build_image)
489
+ except DockerBuildError as exc:
490
+ # The build happens before run_dir/docker.log/task.json exist, so a
491
+ # build failure would otherwise leave an empty result dir with no
492
+ # trace. Persist the build log to docker.log and a BUILD_FAILED
493
+ # synthetic task.json so the failure is visible per-task, then
494
+ # re-raise for the batch dispatcher to record run-level.
495
+ await self._record_build_failure(exc)
496
+ raise
497
+ # The version-label preflight only makes sense for the framework image;
498
+ # a task-supplied Dockerfile won't carry the org.coder-eval.version label.
499
+ if not self._docker_config.dockerfile_path:
500
+ await asyncio.to_thread(_preflight_image_version, image)
501
+ await asyncio.to_thread(self.rt.run_dir.mkdir, parents=True, exist_ok=True)
502
+
503
+ # Docker WORKDIR alignment: resolve the concrete workspace path
504
+ # once, host-side (config value / "auto" -> inspect the built image / fallback
505
+ # /root). Forwarded to the in-container orchestrator via the staged context
506
+ # and rendered as `docker run -w`. None keeps the standard artifacts workspace.
507
+ self._workspace_dir = await asyncio.to_thread(_resolve_workspace_dir, self._docker_config.working_dir, image)
508
+
509
+ # Stage only the inputs (task YAML + context). The *output* dir is
510
+ # the host's run_dir itself, bind-mounted at the same path inside
511
+ # the container so the in-container Orchestrator writes
512
+ # task.json/task.log/task.html/artifacts/ straight into the host
513
+ # filesystem -- no copy step, paths are symmetric inside and out.
514
+ # Sanitize task_id: dataset ids are ``suite_id/row_id`` and the ``/`` breaks mkdtemp (missing parent dir).
515
+ safe_staging_id = _sanitize_container_name_component(self.rt.task.task_id)
516
+ staging = Path(await asyncio.to_thread(tempfile.mkdtemp, prefix=f"coder_eval_docker_{safe_staging_id}_"))
517
+ input_dir = staging / "input"
518
+ await asyncio.to_thread(input_dir.mkdir)
519
+ output_dir = self.rt.run_dir.resolve()
520
+
521
+ try:
522
+ await self._stage_inputs(input_dir)
523
+
524
+ # Give the container a stable, *unique* name so cancellation can
525
+ # target it. PID alone collides under --max-parallel >1 (same
526
+ # host process spawns N concurrent containers); the uuid suffix
527
+ # and replicate_index disambiguate. Sanitize+truncate task_id
528
+ # so dataset row ids like ``suite/row`` don't break docker name
529
+ # validation.
530
+ short_uuid = uuid.uuid4().hex[:8]
531
+ # Docker name limit is 253 chars; keep generous task_id headroom
532
+ # so `docker ps` rows stay readable. Earlier 30-char cap collided
533
+ # visibly on long shared prefixes; 80 covers all realistic ids
534
+ # while leaving room for the suffix.
535
+ safe_task_id = _sanitize_container_name_component(self.rt.task.task_id)[:80]
536
+ container_name = f"coder-eval-{safe_task_id}-r{self.rt.replicate_index}-{os.getpid()}-{short_uuid}"
537
+ # Side-effecting prep that _build_argv must NOT do (argv rendering
538
+ # stays pure for testability). Makes a lean RW copy of ~/.claude
539
+ # under `staging` and records it on self._claude_mount_src for
540
+ # _build_argv to mount. Cleaned up with `staging` in the finally.
541
+ await asyncio.to_thread(self._prepare_host_mounts, staging)
542
+ argv = self._build_argv(input_dir, output_dir, container_name=container_name, image=image)
543
+ logger.info("Running task '%s' in docker: %s", self.rt.task.task_id, " ".join(argv))
544
+ # Prime the heartbeat before the container starts so the
545
+ # watchdog never sees an initial stale state.
546
+ heartbeat_path = output_dir / HEARTBEAT_FILENAME
547
+ await asyncio.to_thread(heartbeat_path.touch)
548
+ heartbeat_task = asyncio.create_task(_heartbeat_loop(heartbeat_path))
549
+ proc = await asyncio.create_subprocess_exec(
550
+ *argv,
551
+ stdout=asyncio.subprocess.PIPE,
552
+ stderr=asyncio.subprocess.STDOUT,
553
+ limit=STDOUT_LINE_LIMIT_BYTES,
554
+ )
555
+ log_path = self.rt.run_dir / "docker.log"
556
+ log_fh = await asyncio.to_thread(log_path.open, "w", encoding="utf-8")
557
+ # Cancellation guard: `docker run --rm` does NOT propagate kill
558
+ # to the container daemon-side. Without this `finally`, Ctrl-C
559
+ # on the host leaves the container running and burning LLM
560
+ # budget. Covers CancelledError, KeyboardInterrupt, and any
561
+ # other exit-by-exception path uniformly.
562
+ try:
563
+ returncode = await self._stream_container_output(proc, log_fh)
564
+ finally:
565
+ heartbeat_task.cancel()
566
+ # await the cancellation so the task doesn't outlive us;
567
+ # narrow to CancelledError so genuine KeyboardInterrupt /
568
+ # SystemExit from a parallel sibling still propagates.
569
+ with contextlib.suppress(asyncio.CancelledError):
570
+ await heartbeat_task
571
+ await asyncio.to_thread(log_fh.close)
572
+ # If proc is still alive we got cancelled mid-flight. Kill
573
+ # the container *and* the docker CLI subprocess. Best-effort,
574
+ # no exception leak from cleanup.
575
+ if proc.returncode is None:
576
+ await self._kill_container(proc, container_name)
577
+
578
+ return await self._parse_result_or_raise(output_dir, returncode, log_path)
579
+ finally:
580
+ await asyncio.to_thread(shutil.rmtree, staging, ignore_errors=True)
581
+
582
+ async def _stage_inputs(self, input_dir: Path) -> None:
583
+ """Serialise the post-override TaskDefinition + lineage/variant context into the
584
+ staging ``input_dir`` (``task.yaml`` + ``context.json``). Pure I/O off the event
585
+ loop; no control-flow change.
586
+ """
587
+ # Always serialise the *post-override* TaskDefinition. We can't use
588
+ # rt.source_yaml because that's the raw on-disk text -- _apply_cli_overrides
589
+ # has since mutated rt.task in-memory (e.g. --model, -D run_limits.max_turns), and the
590
+ # container needs to see those mutations.
591
+ task_yaml_in = input_dir / "task.yaml"
592
+
593
+ def _dump_task_yaml() -> str:
594
+ return yaml.safe_dump(self.rt.task.model_dump(mode="json"), sort_keys=False)
595
+
596
+ task_yaml_text = await asyncio.to_thread(_dump_task_yaml)
597
+ await asyncio.to_thread(task_yaml_in.write_text, task_yaml_text, encoding="utf-8")
598
+ # Lineage + variant metadata so the in-container Orchestrator
599
+ # reconstructs the same context (variant_id is load-bearing for
600
+ # report grouping). source_yaml carries the *raw* on-disk text
601
+ # so the in-container Orchestrator records the same audit trail
602
+ # as the in-process driver (task.json.task_config.source_yaml).
603
+ context_payload = json.dumps(
604
+ {
605
+ "variant_id": self.rt.variant_id,
606
+ "replicate_index": self.rt.replicate_index,
607
+ "config_lineage": {k: v.model_dump(mode="json") for k, v in self.rt.config_lineage.items()},
608
+ "preservation_mode": self.preservation_mode.value,
609
+ "source_yaml": self.rt.source_yaml,
610
+ # Docker WORKDIR alignment: concrete path the in-container
611
+ # orchestrator runs at + captures out (None = standard workspace).
612
+ "workspace_dir": self._workspace_dir,
613
+ }
614
+ )
615
+ await asyncio.to_thread((input_dir / "context.json").write_text, context_payload, encoding="utf-8")
616
+
617
+ async def _stream_container_output(self, proc: asyncio.subprocess.Process, log_fh: TextIO) -> int:
618
+ """Stream the container's stdout, returning its exit code.
619
+
620
+ Wire-format lines emit to the host ``StreamCallback``; plain lines are written
621
+ to ``docker.log``. A single over-limit line is dropped (degrade, not die) — the
622
+ ``readline`` ``ValueError`` resyncs at the next newline. Runs as the inner-``try``
623
+ body of ``run``; the caller owns the ``finally`` cleanup, so this helper never
624
+ touches the heartbeat/log-fh/container teardown.
625
+ """
626
+ assert proc.stdout is not None
627
+ # Explicit readline loop (not `async for`) so a single
628
+ # over-limit line degrades to a dropped line instead of a
629
+ # ValueError that tears the whole task down -- see below.
630
+ while True:
631
+ try:
632
+ raw_line = await proc.stdout.readline()
633
+ except ValueError:
634
+ # A single line exceeded STDOUT_LINE_LIMIT_BYTES.
635
+ # readline() drains the offending bytes and resyncs at
636
+ # the next newline, so we keep streaming. The dropped
637
+ # line is a STREAM_EVENT (host-side live render) or a
638
+ # log line; task.json crosses via the bind mount, not
639
+ # stdout, so the task result is unaffected. Degrade,
640
+ # don't die.
641
+ logger.warning(
642
+ "Dropped a stdout line over %d bytes from task %r's container; continuing to stream.",
643
+ STDOUT_LINE_LIMIT_BYTES,
644
+ self.rt.task.task_id,
645
+ )
646
+ continue
647
+ if not raw_line:
648
+ break
649
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n")
650
+ # Three-way split:
651
+ # - Has the wire-format prefix AND parses cleanly -> emit
652
+ # to the host StreamCallback; do not echo to docker.log
653
+ # (the StreamCallback is the canonical destination).
654
+ # - Has the prefix but parses badly -> wire bug;
655
+ # deserialize_event already logged a WARN. Preserve
656
+ # the raw line in docker.log so it isn't lost.
657
+ # - No prefix -> plain log line.
658
+ if has_prefix(line):
659
+ event = deserialize_event(line)
660
+ if event is not None:
661
+ safe_emit(self.stream_callback, event)
662
+ continue
663
+ # fall through to log preservation
664
+ log_fn = logger.info if self.verbose else logger.debug
665
+ log_fn("[docker:%s] %s", self.rt.task.task_id, line)
666
+ await asyncio.to_thread(log_fh.write, line + "\n")
667
+ await asyncio.to_thread(log_fh.flush)
668
+ return await proc.wait()
669
+
670
+ async def _kill_container(self, proc: asyncio.subprocess.Process, container_name: str) -> None:
671
+ """Best-effort teardown when cancelled mid-stream with the container still alive.
672
+
673
+ Called from ``run``'s inner ``finally`` (after heartbeat-cancel + log-fh close),
674
+ guarded by ``if proc.returncode is None``. ``docker run --rm`` does NOT propagate
675
+ a host-side kill to the daemon, so kill the container by name and then the docker
676
+ CLI subprocess. No exception leaks from cleanup; suppression is narrowed to
677
+ CancelledError so KeyboardInterrupt / SystemExit from parallel siblings propagate.
678
+ """
679
+ logger.warning("Cleanup: killing container %s", container_name)
680
+ try:
681
+ kill_result = await asyncio.to_thread(
682
+ subprocess.run,
683
+ ["docker", "kill", container_name],
684
+ capture_output=True,
685
+ check=False,
686
+ timeout=10,
687
+ )
688
+ if kill_result.returncode == 0:
689
+ logger.info("Container %s killed cleanly.", container_name)
690
+ else:
691
+ # Non-zero from `docker kill` typically means the
692
+ # container was already gone (race with --rm) OR
693
+ # the daemon refused. Surface stderr so the
694
+ # ambiguity is debuggable.
695
+ logger.warning(
696
+ "docker kill %s returned %s; container may already be gone or daemon refused: %s",
697
+ container_name,
698
+ kill_result.returncode,
699
+ kill_result.stderr.decode("utf-8", errors="replace").strip(),
700
+ )
701
+ except subprocess.TimeoutExpired:
702
+ # Daemon hung; container may now be orphaned daemon-side.
703
+ # Loud so an operator notices and prunes manually.
704
+ logger.error(
705
+ "docker kill %s timed out after 10s; container may be orphaned. Investigate `docker ps`.",
706
+ container_name,
707
+ )
708
+ except (OSError, subprocess.SubprocessError) as kill_exc:
709
+ logger.warning("docker kill failed: %s", kill_exc)
710
+ with contextlib.suppress(ProcessLookupError):
711
+ proc.kill()
712
+ # Narrow to CancelledError -- a generic BaseException
713
+ # catch here would silently eat KeyboardInterrupt /
714
+ # SystemExit propagation from parallel tasks.
715
+ with contextlib.suppress(asyncio.CancelledError):
716
+ await proc.wait()
717
+
718
+ async def _parse_result_or_raise(self, output_dir: Path, returncode: int, log_path: Path) -> EvaluationResult:
719
+ """Read back ``task.json`` (the only artifact crossing the boundary) and parse it.
720
+
721
+ If the container exited without producing it, persist a synthetic ERROR
722
+ task.json and raise ``DockerRunError`` so the batch dispatcher records the
723
+ failure as an ERROR-status result.
724
+ """
725
+ task_json = output_dir / "task.json"
726
+ if not await asyncio.to_thread(task_json.exists):
727
+ # The container died before its orchestrator's `finally` could
728
+ # write task.json (e.g. it was torn down by the cleanup above
729
+ # after a host-side stream failure, or killed externally).
730
+ # Persist a synthetic ERROR task.json so the test stays
731
+ # visible on dashboards/timelines instead of silently
732
+ # vanishing -- the batch layer's in-memory skeleton never
733
+ # reaches the per-task dir.
734
+ error = DockerRunError(
735
+ f"Container exited with code {returncode} without producing task.json. "
736
+ + f"See {log_path} for container output."
737
+ )
738
+ await self._write_synthetic_task_json(task_json, error)
739
+ raise error
740
+
741
+ # output_dir IS rt.run_dir -- no copy needed.
742
+ task_json_text = await asyncio.to_thread(task_json.read_text, encoding="utf-8")
743
+ try:
744
+ result = EvaluationResult.model_validate_json(task_json_text)
745
+ except ValueError as exc:
746
+ # Present but unparseable (schema skew from a stale image, or a
747
+ # truncated/torn write). Degrade like the missing-file branch
748
+ # rather than crashing with an uncaught ValidationError/JSONDecodeError.
749
+ raise await self._handle_malformed_task_json(task_json, log_path, exc) from exc
750
+ self._warn_on_version_mismatch(result)
751
+ return result
752
+
753
+ async def _handle_malformed_task_json(self, task_json: Path, log_path: Path, exc: ValueError) -> DockerRunError:
754
+ """Degrade a present-but-malformed task.json; return the DockerRunError to raise.
755
+
756
+ Triggered by a present-but-unparseable task.json -- most realistically a
757
+ schema skew between a stale ``:latest`` image and the host (the version
758
+ checks only warn), or a truncated/torn write. Mirrors the missing-file
759
+ branch and the batch.py recovery paths: log naming the path, move the
760
+ original aside to ``task.json.malformed`` (so its possibly-recoverable
761
+ content isn't masked AND so the synthetic write lands --
762
+ ``_write_synthetic_task_json`` never overwrites an existing file),
763
+ persist a synthetic ERROR record (per-task dashboard visibility), and
764
+ return the error for the caller to raise (the batch layer records the
765
+ run-level ERROR). Best-effort throughout: a failed move is logged, never
766
+ masking the raise.
767
+ """
768
+ logger.warning("Malformed task.json at %s: %s", task_json, exc)
769
+ sidecar = task_json.with_suffix(task_json.suffix + ".malformed")
770
+
771
+ def _move() -> None:
772
+ os.replace(task_json, sidecar) # atomic; overwrites any stale prior .malformed
773
+
774
+ try:
775
+ await asyncio.to_thread(_move)
776
+ except OSError as move_exc:
777
+ logger.warning("Failed to preserve malformed task.json %s: %s", task_json, move_exc)
778
+
779
+ error = DockerRunError(f"task.json at {task_json} is malformed. See {log_path} for container output.")
780
+ await self._write_synthetic_task_json(task_json, error)
781
+ return error
782
+
783
+ async def _record_build_failure(self, exc: DockerBuildError) -> None:
784
+ """Persist a failed image build so it is visible, not a silent empty dir.
785
+
786
+ ``_build_image`` runs before ``run_dir``, ``docker.log``, or ``task.json``
787
+ exist, so a build failure used to leave an empty result directory with no
788
+ status and no log. This creates ``run_dir``, writes the captured build log
789
+ to ``docker.log`` (where every per-task consumer already looks for
790
+ container output), and writes a synthetic ``BUILD_FAILED`` task.json.
791
+ Best-effort: any IO failure here is logged and never masks the
792
+ ``DockerBuildError`` the caller re-raises.
793
+ """
794
+ try:
795
+ await asyncio.to_thread(self.rt.run_dir.mkdir, parents=True, exist_ok=True)
796
+ log_path = self.rt.run_dir / "docker.log"
797
+ await asyncio.to_thread(log_path.write_text, exc.build_log or str(exc), encoding="utf-8")
798
+ await self._write_synthetic_task_json(self.rt.run_dir / "task.json", exc, status=FinalStatus.BUILD_FAILED)
799
+ except OSError as io_exc: # pragma: no cover - defensive
800
+ logger.warning("Failed to record build failure for %s: %s", self.rt.task.task_id, io_exc)
801
+
802
+ async def _write_synthetic_task_json(
803
+ self, target: Path, error: DockerRunError, *, status: FinalStatus = FinalStatus.ERROR
804
+ ) -> None:
805
+ """Persist a minimal error task.json for a container that died pre-write.
806
+
807
+ A container killed mid-task (SIGKILL, or torn down by our own
808
+ cancellation cleanup) never reaches the in-container `finally` that
809
+ writes task.json, so without this the task is recorded only in the
810
+ batch layer's in-memory error skeleton and vanishes from every
811
+ per-task consumer (dashboard, timelines). Reuses
812
+ :func:`build_error_result` -- the documented mirror of
813
+ ``_create_error_task_result`` -- and the Orchestrator's own
814
+ ``model_dump_json(indent=2)`` serialization so downstream readers
815
+ parse it unchanged.
816
+
817
+ Atomic (tmp + os.replace), never overwrites an existing task.json
818
+ (if the container won the race after all, the real result wins), and
819
+ best-effort: a write failure logs a warning and never masks the
820
+ DockerRunError the caller is about to raise.
821
+ """
822
+ result = build_error_result(self.rt, error, status=status)
823
+
824
+ def _write() -> None:
825
+ if target.exists():
826
+ return
827
+ tmp = target.with_suffix(target.suffix + ".synthetic.tmp")
828
+ tmp.write_text(result.model_dump_json(indent=2), encoding="utf-8")
829
+ os.replace(tmp, target)
830
+
831
+ try:
832
+ await asyncio.to_thread(_write)
833
+ except OSError as exc:
834
+ logger.warning("Failed to write synthetic task.json to %s: %s", target, exc)
835
+
836
+ def _warn_on_version_mismatch(self, result: EvaluationResult) -> None:
837
+ """Warn loudly if the in-container coder_eval version != the host's.
838
+
839
+ Reproducibility is one of two reasons users pick driver:docker.
840
+ Without this check, an outdated image silently runs stale code
841
+ against a refreshed host -- a class of "works on my machine"
842
+ regression that's near-impossible to debug. The host already
843
+ embeds its own version in environment_info before this point.
844
+ """
845
+ from importlib.metadata import PackageNotFoundError, version
846
+
847
+ try:
848
+ host_version = version("coder-eval")
849
+ except PackageNotFoundError:
850
+ return
851
+ env_info = result.environment_info or {}
852
+ if "coder_eval" not in env_info:
853
+ # Surface the silent-disable. Future refactor removing this key
854
+ # would otherwise stop the version check without anyone noticing.
855
+ logger.warning(
856
+ "Cannot verify container coder_eval version: result.environment_info missing 'coder_eval' key."
857
+ )
858
+ return
859
+ container_version = env_info["coder_eval"]
860
+ if container_version and container_version != host_version:
861
+ logger.warning(
862
+ "coder_eval version mismatch -- host %s, container %s. Rebuild image with `make docker-image`.",
863
+ host_version,
864
+ container_version,
865
+ )
866
+
867
+ @staticmethod
868
+ def _sensitive_source_paths() -> list[Path]:
869
+ """Host paths whose auto-mount should emit a loud warning.
870
+
871
+ Not a hard denylist: there are legitimate task shapes that need to
872
+ read e.g. ``~/.aws`` (cloud-deploy validators). Warning gives the
873
+ author visibility without breaking those tasks.
874
+ """
875
+ home = Path.home()
876
+ candidates = [
877
+ home / ".ssh",
878
+ home / ".aws",
879
+ home / ".gnupg",
880
+ home / ".config" / "gh",
881
+ home / ".kube",
882
+ Path("/etc"),
883
+ ]
884
+ return [p.resolve() for p in candidates if p.exists()]
885
+
886
+ def _prepare_host_mounts(self, staging: Path) -> None:
887
+ """Side-effecting prep that ``_build_argv`` must not do.
888
+
889
+ Makes a *lean copy* of the host's ``~/.claude`` into a throwaway dir
890
+ under ``staging`` and records it on ``self._claude_mount_src``.
891
+ ``_build_argv`` then bind-mounts that copy read-WRITE at the host's
892
+ ``~/.claude`` path (HOME is forwarded, so the path is symmetric inside
893
+ the container). Mounting a copy — rather than the host dir read-only —
894
+ lets the in-container CLI write anywhere under ``~/.claude`` without
895
+ ever mutating the host's real state.
896
+
897
+ The copy skips heavy, container-irrelevant per-session state
898
+ (``CLAUDE_COPY_IGNORE``) so it stays cheap even in parallel batches.
899
+
900
+ The copy lives under ``staging``, which ``run()`` removes in its
901
+ ``finally``, so there is no extra cleanup to track. Argv rendering must
902
+ stay pure (it may run twice — for logging then exec), so the copy is
903
+ made here, exactly once, rather than in ``_build_argv``.
904
+ """
905
+ if os.environ.get("CODER_EVAL_NO_CLAUDE_MOUNT"):
906
+ return
907
+ host_claude_dir = Path.home() / ".claude"
908
+ if not host_claude_dir.is_dir():
909
+ return
910
+ claude_copy = staging / "claude-home"
911
+ _copy_claude_home(host_claude_dir, claude_copy)
912
+ self._claude_mount_src = claude_copy
913
+
914
+ def _build_image(self) -> str:
915
+ """Resolve the image to run, building from a Dockerfile when configured.
916
+
917
+ When ``docker.dockerfile_path`` is set it overrides ``docker.image``:
918
+ we shell out to ``docker build`` using the Dockerfile's parent directory
919
+ as the build context (so relative ``COPY`` paths resolve) and tag the
920
+ result with a deterministic, per-task name so Docker's layer cache is
921
+ reused across runs. ``docker.build`` (:class:`DockerBuildConfig`) adds
922
+ ``--build-arg`` / ``--secret`` / extra flags; the build runs with
923
+ BuildKit enabled. Otherwise the configured ``image`` is returned
924
+ unchanged.
925
+
926
+ **Contract:** the container runs the coder-eval orchestrator. The image
927
+ bakes no ``ENTRYPOINT``; the host pins it at run time via
928
+ ``docker run --entrypoint`` (see :meth:`_build_argv`). A task Dockerfile
929
+ must therefore start ``FROM coder-eval-agent:<version>`` and only ADD
930
+ task-specific layers, so the runtime (the ``coder_eval_entrypoint.sh``
931
+ script + the ``coder-eval`` CLI + the ``org.coder-eval.version`` label)
932
+ is present. After building we assert that label is present and fail with
933
+ an actionable error otherwise -- without this, a bare ``FROM ubuntu``
934
+ image builds fine, then dies at ``docker run`` with a cryptic
935
+ ``exec: "/usr/local/bin/coder_eval_entrypoint.sh": no such file``.
936
+
937
+ Side-effecting (network + docker daemon state); call via
938
+ ``asyncio.to_thread`` from :meth:`run`, never from :meth:`_build_argv`,
939
+ which must stay pure.
940
+
941
+ Returns:
942
+ The image reference to pass to ``docker run``.
943
+
944
+ Raises:
945
+ DockerRunError: If ``docker build`` exits non-zero, or the built
946
+ image is not a coder-eval runtime image (missing the
947
+ ``org.coder-eval.version`` label).
948
+ """
949
+ cfg = self._docker_config
950
+ if not cfg.dockerfile_path:
951
+ return cfg.image
952
+ dockerfile = Path(cfg.dockerfile_path)
953
+ context = dockerfile.parent
954
+ # Image repository names must be lowercase; task ids are typically
955
+ # already kebab-case, but lowercase defensively. Deterministic tag ->
956
+ # Docker layer cache is reused across runs of the same task.
957
+ safe_id = _sanitize_container_name_component(self.rt.task.task_id).lower()
958
+ image = f"coder-eval-task-{safe_id}:built"
959
+
960
+ # Assemble the build argv from config: base flags, then task-supplied
961
+ # --build-arg / --secret / extra flags, then the context (always last).
962
+ build = cfg.build
963
+ argv = ["docker", "build", "-t", image, "-f", str(dockerfile)]
964
+ for key, value in build.args.items():
965
+ argv += ["--build-arg", f"{key}={os.path.expandvars(value)}"]
966
+ for spec in build.secrets:
967
+ argv += ["--secret", spec]
968
+ argv += build.extra_args
969
+ argv.append(str(context))
970
+
971
+ # BuildKit (required for `--secret`) is inherited from the invoking
972
+ # environment by default; `build.buildkit` forces it on/off when set.
973
+ env = os.environ.copy()
974
+ if build.buildkit is not None:
975
+ env["DOCKER_BUILDKIT"] = "1" if build.buildkit else "0"
976
+ if build.secrets and env.get("DOCKER_BUILDKIT") != "1":
977
+ logger.warning(
978
+ "docker.build.secrets is set but BuildKit is not enabled (DOCKER_BUILDKIT=%s); "
979
+ + "secrets require BuildKit. Set docker.build.buildkit: true or export DOCKER_BUILDKIT=1.",
980
+ env.get("DOCKER_BUILDKIT", "<unset>"),
981
+ )
982
+
983
+ # Log only high-level info here.
984
+ logger.info("Building docker image %s from %s (context %s)", image, dockerfile, context)
985
+ try:
986
+ subprocess.run(argv, check=True, capture_output=True, text=True, encoding="utf-8", env=env)
987
+ except subprocess.CalledProcessError as exc:
988
+ # Preserve the full build output (stdout+stderr) so run() can persist
989
+ # it to docker.log; the message keeps the concise stderr tail.
990
+ build_log = (exc.stdout or "") + (exc.stderr or "")
991
+ raise DockerBuildError(
992
+ f"Failed to build Docker image from {dockerfile}: {exc.stderr}", build_log=build_log
993
+ ) from exc
994
+ self._assert_runtime_image(image, dockerfile)
995
+ return image
996
+
997
+ def _assert_runtime_image(self, image: str, dockerfile: Path) -> None:
998
+ """Fail fast unless the built image carries the coder-eval runtime.
999
+
1000
+ The host pins ``--entrypoint`` at run time, so we no longer inspect the
1001
+ baked ``ENTRYPOINT``; instead we verify the image is a coder-eval runtime
1002
+ image by checking for the ``org.coder-eval.version`` label, which
1003
+ docker/Dockerfile stamps and any ``FROM coder-eval-agent`` task inherits.
1004
+ This is the only pre-run validation for a ``dockerfile_path`` task
1005
+ (``run()`` skips :func:`_preflight_image_version` for that case), so
1006
+ without it a bare ``FROM ubuntu`` image would build, then die at
1007
+ ``docker run`` with a cryptic ``exec ...coder_eval_entrypoint.sh: no
1008
+ such file``. A docker/inspect failure is soft (debug-logged, no raise):
1009
+ the subsequent ``docker run`` surfaces any real problem.
1010
+
1011
+ Raises:
1012
+ DockerRunError: If the image carries no ``org.coder-eval.version``
1013
+ label (i.e. it is not built ``FROM coder-eval-agent``).
1014
+ """
1015
+ try:
1016
+ result = subprocess.run(
1017
+ [
1018
+ "docker",
1019
+ "image",
1020
+ "inspect",
1021
+ "--format",
1022
+ '{{ index .Config.Labels "org.coder-eval.version" }}',
1023
+ image,
1024
+ ],
1025
+ check=True,
1026
+ capture_output=True,
1027
+ text=True,
1028
+ encoding="utf-8",
1029
+ timeout=10,
1030
+ )
1031
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as exc:
1032
+ logger.debug("Could not inspect labels of built image %s: %s", image, exc)
1033
+ return
1034
+ # `docker inspect` renders a missing label as the empty string (the Go
1035
+ # template's zero value); "<no value>" can occur on older clients.
1036
+ label = result.stdout.strip()
1037
+ if not label or label == "<no value>":
1038
+ base = get_default_docker_image_tag()
1039
+ raise DockerRunError(
1040
+ f"Image built from {dockerfile} is not a coder-eval runtime image "
1041
+ + "(missing the org.coder-eval.version label). The container must run the "
1042
+ + f"in-container orchestrator, so a task Dockerfile must start `FROM {base}` "
1043
+ + "(the framework image, built via `make docker-image`) and only add "
1044
+ + "task-specific layers on top. See docs/DOCKER_ISOLATION.md."
1045
+ )
1046
+
1047
+ def _build_argv(
1048
+ self, input_dir: Path, output_dir: Path, *, container_name: str, image: str | None = None
1049
+ ) -> list[str]:
1050
+ cfg = self._docker_config
1051
+ # `image` is resolved by run() via _build_image() (which may shell out to
1052
+ # `docker build`). _build_argv stays pure -- no side effects -- so it
1053
+ # remains testable without a docker daemon. Fall back to the configured
1054
+ # image when called directly (e.g. unit tests of mount rendering).
1055
+ if image is None:
1056
+ image = cfg.image
1057
+
1058
+ argv: list[str] = ["docker", "run", "--rm", "--name", container_name]
1059
+
1060
+ # Pin the framework entrypoint at run time rather than trusting whatever
1061
+ # the task image baked into ENTRYPOINT. This makes the orchestrator launch
1062
+ # robust to a task Dockerfile that sets its own ENTRYPOINT/CMD (or clears
1063
+ # it via `ENTRYPOINT []`). `--entrypoint` resets the image CMD, which is
1064
+ # fine -- the run command (`--output`/`--task-dir`, appended after the
1065
+ # image) is passed explicitly below and is forwarded to the entrypoint.
1066
+ argv += ["--entrypoint", CONTAINER_ENTRYPOINT]
1067
+
1068
+ if cfg.network == "none":
1069
+ argv += ["--network", "none"]
1070
+ else:
1071
+ argv += ["--network", "bridge"]
1072
+
1073
+ if self._limits.max_memory_mb:
1074
+ argv += ["--memory", f"{self._limits.max_memory_mb}m"]
1075
+ if self._limits.max_cpus is not None:
1076
+ argv += ["--cpus", str(self._limits.max_cpus)]
1077
+ if self._limits.max_pids is not None:
1078
+ argv += ["--pids-limit", str(self._limits.max_pids)]
1079
+
1080
+ # Forward environment variables: explicit allowlist (optionally extended via env_passthrough_extra).
1081
+ # `--env VAR` (name-only) tells docker to copy the value from our current env at
1082
+ # run time, so secrets stay out of the rendered argv list that we log.
1083
+ #
1084
+ # The run's backend rides this same path: API_BACKEND is in the default allowlist,
1085
+ # and `--backend` syncs it into os.environ at the CLI (run_command), so it forwards
1086
+ # here exactly like every other allowlisted var. A flag that only mutated in-process
1087
+ # Settings would be dropped at the container boundary and the in-container Settings
1088
+ # would silently default to DIRECT — downgrading the judge (and agent) route.
1089
+ merged_allowlist = set(cfg.env_passthrough) | set(cfg.env_passthrough_extra)
1090
+ for env_var in merged_allowlist:
1091
+ if env_var in os.environ:
1092
+ argv += ["--env", env_var]
1093
+
1094
+ # Signal to in-container agents that the harness already provides OS-level
1095
+ # isolation. The Codex agent reads this to fall back to its full-access
1096
+ # sandbox: Codex's Landlock-backed read-only / workspace-write sandboxes
1097
+ # can't initialize inside a container and otherwise fail writes silently.
1098
+ argv += ["--env", "CODER_EVAL_IN_CONTAINER=1"]
1099
+
1100
+ # Hard-disable telemetry INSIDE the container. The app ships a baked-in
1101
+ # default connection string, so without this the in-container orchestrator
1102
+ # would emit CoderEval.Task.End — and the host re-emits the same event after
1103
+ # the container result is parsed (orchestration/batch.py), double-counting
1104
+ # every docker-driver task. The invariant is "container silent, host emits
1105
+ # once"; this restores it regardless of the host's own telemetry setting.
1106
+ # Explicit value (not name-only) so it overrides any inherited/baked value.
1107
+ argv += ["--env", "TELEMETRY_ENABLED=false"]
1108
+
1109
+ argv += ["-v", f"{input_dir.resolve()}:{CONTAINER_INPUT_DIR}:ro"]
1110
+ # Mount the host run_dir to the container's standard output location
1111
+ # so the in-container Orchestrator writes task.json/task.log/etc.
1112
+ # directly to the host filesystem via bind-mount.
1113
+ argv += ["-v", f"{output_dir}:{CONTAINER_OUTPUT_DIR}"]
1114
+ # Mount the original task dir at the SAME host path so the
1115
+ # in-container Orchestrator can set TASK_DIR (used by run_command
1116
+ # criteria via `$TASK_DIR/foo.json`) to a path that resolves
1117
+ # identically inside and outside the container.
1118
+ host_task_dir: Path | None = None
1119
+ if self.rt.task_file:
1120
+ host_task_dir = self.rt.task_file.parent.resolve()
1121
+ argv += ["-v", f"{host_task_dir}:{host_task_dir}:ro"]
1122
+ # Forward the host's Claude Code OAuth state so the in-container CLI
1123
+ # inherits the same login as the host. We mount a *throwaway lean copy*
1124
+ # of ~/.claude (made by _prepare_host_mounts) read-WRITE at the host's
1125
+ # ~/.claude path — HOME is forwarded, so the path is symmetric inside
1126
+ # the container. The container can therefore write anywhere under
1127
+ # ~/.claude (settings, session ephemera, cache) without ever mutating
1128
+ # the host's real ~/.claude. _claude_mount_src is None when ~/.claude
1129
+ # doesn't exist or the mount is opted out (CODER_EVAL_NO_CLAUDE_MOUNT=1).
1130
+ if self._claude_mount_src is not None:
1131
+ host_claude_dir = Path.home() / ".claude"
1132
+ argv += ["-v", f"{self._claude_mount_src}:{host_claude_dir}"]
1133
+
1134
+ # Auto-mount host paths the task references so they resolve inside
1135
+ # the container at the *same* path they have on the host.
1136
+ # Includes:
1137
+ # - Claude Code plugin dirs (`agent.plugins[].path`)
1138
+ # - Template directories (`sandbox.template_sources[].path` for
1139
+ # TemplateDirSource entries -- already absolute after
1140
+ # resolve_template_paths runs on the host).
1141
+ # Reference files (`task.reference.file`) and `run_command`
1142
+ # criteria that use `$TASK_DIR/...` are covered by the symmetric
1143
+ # task_dir mount above. ``mounted`` dedupes overlapping entries.
1144
+ mounted: set[Path] = set()
1145
+ # Auto-mount sources that look like credential / secret dirs get a
1146
+ # loud warning. Task YAMLs typically come from in-house suite authors,
1147
+ # but the `plugin.path` / `reference.directory` / `template_sources`
1148
+ # fields are user-controlled strings, and a typo (or a hostile suite)
1149
+ # can silently expose `~/.ssh` etc. Warning, not hard fail, because
1150
+ # legitimate uses exist (a task that does in fact want to read
1151
+ # `~/.aws/config`). The warning surfaces the surprise.
1152
+ sensitive_sources = self._sensitive_source_paths()
1153
+
1154
+ def _auto_mount(raw_path: str | None, *, dir_only: bool = True) -> None:
1155
+ if not raw_path:
1156
+ return
1157
+ resolved = Path(os.path.expandvars(os.path.expanduser(raw_path))).resolve()
1158
+ # File paths get mounted as the parent dir so a single -v covers
1159
+ # the file; container-side reads still resolve at the same path.
1160
+ target = resolved if (dir_only or resolved.is_dir()) else resolved.parent
1161
+ if target in mounted or not target.is_dir():
1162
+ return
1163
+ for sensitive in sensitive_sources:
1164
+ if target == sensitive or sensitive in target.parents:
1165
+ logger.warning(
1166
+ "Auto-mounting sensitive host path %s into container; fix task YAML if unintended.",
1167
+ target,
1168
+ )
1169
+ break
1170
+ mounted.add(target)
1171
+ argv.extend(["-v", f"{target}:{target}:ro"])
1172
+
1173
+ plugins = (self.rt.task.agent.plugins if self.rt.task.agent else None) or []
1174
+ for plugin in plugins:
1175
+ _auto_mount(plugin.get("path") if isinstance(plugin, dict) else None)
1176
+
1177
+ from coder_eval.models import TemplateDirSource
1178
+
1179
+ sandbox_cfg = self.rt.task.sandbox
1180
+ for source in (sandbox_cfg.template_sources or []) if sandbox_cfg else []:
1181
+ if isinstance(source, TemplateDirSource):
1182
+ _auto_mount(source.path)
1183
+
1184
+ # Defensive: system_prompt_file is normally inlined into
1185
+ # system_prompt by load_task / experiment resolution, but a variant
1186
+ # could conceivably inject an absolute path that survives. Cover
1187
+ # that path so the in-container Orchestrator can read it.
1188
+ agent_cfg = self.rt.task.agent
1189
+ if agent_cfg and agent_cfg.system_prompt_file:
1190
+ _auto_mount(agent_cfg.system_prompt_file, dir_only=False)
1191
+
1192
+ # reference.file / reference.directory: if a task ships absolute
1193
+ # paths (or relative paths that escape the task_dir mount via
1194
+ # ``..``), they must be mounted explicitly. Relative paths under
1195
+ # task_dir are already covered by the symmetric task_dir mount.
1196
+ reference = self.rt.task.reference
1197
+ if reference is not None:
1198
+ _auto_mount(reference.file, dir_only=False)
1199
+ _auto_mount(reference.directory)
1200
+ for mount in cfg.extra_mounts:
1201
+ normalized = _validate_extra_mount(mount)
1202
+ argv += ["-v", normalized]
1203
+
1204
+ # Docker WORKDIR alignment: run the agent at the image's own WORKDIR. Set
1205
+ # the container's initial cwd via `-w` (the in-container orchestrator also
1206
+ # runs the agent there). NO bind mount targets it -- capture is a copy-out
1207
+ # (see Orchestrator._cleanup), not a mount, so baked inputs/HOME survive.
1208
+ if self._workspace_dir is not None:
1209
+ _assert_workspace_not_reserved(self._workspace_dir)
1210
+ argv += ["-w", self._workspace_dir]
1211
+
1212
+ argv += [image]
1213
+ # Pass the container-side output path (the input/output are bound at
1214
+ # container-side defaults, so we just use those).
1215
+ if self.verbose:
1216
+ argv += ["-v"]
1217
+ argv += ["--output", str(CONTAINER_OUTPUT_DIR)]
1218
+ if host_task_dir is not None:
1219
+ argv += ["--task-dir", str(host_task_dir)]
1220
+ return argv
1221
+
1222
+
1223
+ def build_error_result(
1224
+ rt: ResolvedTask, exc: BaseException, *, status: FinalStatus = FinalStatus.ERROR
1225
+ ) -> EvaluationResult:
1226
+ """Synthesize an error-status EvaluationResult for a Docker-runner failure.
1227
+
1228
+ Mirrors the shape produced by ``_create_error_task_result`` in
1229
+ ``orchestration.batch`` so downstream reporting code doesn't have to
1230
+ special-case Docker failures. ``status`` lets the caller distinguish a
1231
+ failed image build (:data:`FinalStatus.BUILD_FAILED`) from a generic ERROR;
1232
+ for a build failure the full build log is carried into ``error_log_tail``.
1233
+ """
1234
+ build_log = getattr(exc, "build_log", "") or ""
1235
+ description = (
1236
+ "Docker image build failed"
1237
+ if status == FinalStatus.BUILD_FAILED
1238
+ else f"Docker run failed: {type(exc).__name__}"
1239
+ )
1240
+ return EvaluationResult(
1241
+ task_id=rt.task.task_id,
1242
+ task_description=description,
1243
+ variant_id=rt.variant_id,
1244
+ agent_type=AgentKind.UNKNOWN,
1245
+ started_at=datetime.now(),
1246
+ final_status=status,
1247
+ error_message=str(exc),
1248
+ # Match the orchestrator's task.log tail ceiling; docker.log keeps the
1249
+ # full unbounded build output regardless.
1250
+ error_log_tail=build_log[-DEFAULT_LOG_TAIL_MAX_BYTES:] if build_log else None,
1251
+ iteration_count=0,
1252
+ environment_info={},
1253
+ )