pluginbench 0.5.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.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
|
@@ -0,0 +1,2946 @@
|
|
|
1
|
+
"""Harbor execution with a stopped-container submission boundary.
|
|
2
|
+
|
|
3
|
+
PluginBench freezes the installed agent image; Harbor runs the agent. The
|
|
4
|
+
workspace is copied only after Docker proves the container is stopped, then
|
|
5
|
+
the native separate verifier grades collected artifacts and owned resources
|
|
6
|
+
are removed. Source and graded-artifact evidence remain distinct.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import copy
|
|
13
|
+
import fcntl
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib
|
|
16
|
+
import importlib.metadata
|
|
17
|
+
import inspect
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
from contextlib import ExitStack
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path, PurePosixPath
|
|
30
|
+
from typing import Any
|
|
31
|
+
from uuid import uuid4
|
|
32
|
+
|
|
33
|
+
from pluginbench import codex, components, isolation, submissions, native_treatments
|
|
34
|
+
from pluginbench.treatments import Treatment
|
|
35
|
+
from pluginbench.native_treatments import NativeCapabilities
|
|
36
|
+
from pluginbench.code_diff import _is_source_path, _is_test_path
|
|
37
|
+
from pluginbench.assessment_metrics import inspect_snapshot
|
|
38
|
+
from pluginbench.assessment_metrics import EXCLUDED_NAMES
|
|
39
|
+
from pluginbench.processes import run_command
|
|
40
|
+
|
|
41
|
+
HARBOR_VERSION = "0.24.0"
|
|
42
|
+
PROTOCOL = "harbor-docker-v1"
|
|
43
|
+
_OWNER_LABEL = "org.pluginbench.trial"
|
|
44
|
+
_BASE_TOOLS = "curl bash node npm"
|
|
45
|
+
_DOCKER_TIMEOUT = 60
|
|
46
|
+
_CODEX_TRANSPORT_VERSION = "codex-stdout-stderr-v1"
|
|
47
|
+
_CODEX_RUN_SHA256 = "5b14fbcc889a398f80c7b5f7e2a621efc1337ba72e748fafefbab2ad09ad86bd"
|
|
48
|
+
_CODEX_EXEC_PREFIX = (
|
|
49
|
+
"if [ -s ~/.nvm/nvm.sh ]; then . ~/.nvm/nvm.sh; fi; "
|
|
50
|
+
"codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check --model "
|
|
51
|
+
)
|
|
52
|
+
_CODEX_EXEC_SUFFIX = "2>&1 </dev/null | tee /logs/agent/codex.txt"
|
|
53
|
+
NATIVE_PROVIDER_HOSTS = {
|
|
54
|
+
"codex": ["api.openai.com", "chatgpt.com", "auth.openai.com"],
|
|
55
|
+
"claude": ["api.anthropic.com", "console.anthropic.com", "claude.ai"],
|
|
56
|
+
}
|
|
57
|
+
NATIVE_TRANSPORT_POLICY = "native-cli-provider-hosts-v1"
|
|
58
|
+
_PREPARATION_POLICY = "native-task-and-qualified-patch-source-v3"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class _NativePreparation:
|
|
63
|
+
admission: dict[str, object]
|
|
64
|
+
runtime: dict[str, object]
|
|
65
|
+
base: dict[str, str]
|
|
66
|
+
grader: dict[str, str]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _assert_codex_transport_source() -> str:
|
|
70
|
+
if importlib.metadata.version("harbor") != HARBOR_VERSION:
|
|
71
|
+
raise ValueError("Codex transport requires the pinned Harbor version")
|
|
72
|
+
agent = importlib.import_module("harbor.agents.installed.codex").Codex
|
|
73
|
+
digest = hashlib.sha256(inspect.getsource(agent.run).encode()).hexdigest()
|
|
74
|
+
if digest != _CODEX_RUN_SHA256:
|
|
75
|
+
raise ValueError("Harbor Codex.run changed; refusing the native transport adapter")
|
|
76
|
+
return digest
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _object(value: object, label: str) -> dict[str, Any]:
|
|
80
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
81
|
+
raise ValueError(f"{label} must be an object")
|
|
82
|
+
return {key: item for key, item in value.items()}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _base_inventory_command(base: str) -> str:
|
|
86
|
+
"""Shell that prints HEAD and the base tree; on failure it prints git's error instead.
|
|
87
|
+
|
|
88
|
+
`docker logs` returns only the container's standard output, and the inventory must stay free
|
|
89
|
+
of warnings, so git's standard error is held back and printed only when a command fails.
|
|
90
|
+
"""
|
|
91
|
+
git = "git -c safe.directory=/app"
|
|
92
|
+
return (
|
|
93
|
+
"set -eu; exec 3>&1; "
|
|
94
|
+
f"error=$({{ {git} rev-parse --verify HEAD >&3 && "
|
|
95
|
+
f"{git} ls-tree -r -z --full-tree {base} >&3; }} 2>&1) "
|
|
96
|
+
'|| { code=$?; printf \'%s\\n\' "$error"; exit "$code"; }'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _docker(
|
|
101
|
+
*arguments: str,
|
|
102
|
+
timeout: int = _DOCKER_TIMEOUT,
|
|
103
|
+
expected_runtime: dict[str, object] | None = None,
|
|
104
|
+
receipt: Path | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
argv = ["docker"]
|
|
107
|
+
env = dict(os.environ)
|
|
108
|
+
if expected_runtime is not None:
|
|
109
|
+
cli = _object(expected_runtime.get("docker_cli"), "frozen Docker CLI")
|
|
110
|
+
argv = [_frozen_executable(cli, "CLI")["resolved_path"]]
|
|
111
|
+
engine = _object(expected_runtime.get("docker_engine"), "frozen Docker engine")
|
|
112
|
+
endpoint = engine.get("endpoint")
|
|
113
|
+
if not isinstance(endpoint, str) or not endpoint.startswith("unix://"):
|
|
114
|
+
raise ValueError("Frozen Docker endpoint must be a local Unix socket")
|
|
115
|
+
argv.extend(["--host", endpoint])
|
|
116
|
+
env.pop("DOCKER_CONTEXT", None)
|
|
117
|
+
env.pop("DOCKER_HOST", None)
|
|
118
|
+
command = [*argv, *arguments]
|
|
119
|
+
try:
|
|
120
|
+
result = run_command(command, cwd=Path.cwd(), env=env, timeout=timeout)
|
|
121
|
+
except subprocess.TimeoutExpired as error:
|
|
122
|
+
if receipt is not None:
|
|
123
|
+
receipt.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
receipt.write_text(
|
|
125
|
+
json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"command": command,
|
|
128
|
+
"returncode": None,
|
|
129
|
+
"timed_out": True,
|
|
130
|
+
"stdout": (error.output or b"").decode("utf-8", errors="replace"),
|
|
131
|
+
"stderr": (error.stderr or b"").decode("utf-8", errors="replace"),
|
|
132
|
+
},
|
|
133
|
+
sort_keys=True,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
raise
|
|
137
|
+
if receipt is not None:
|
|
138
|
+
receipt.parent.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
receipt.write_text(
|
|
140
|
+
json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"command": command,
|
|
143
|
+
"returncode": result.returncode,
|
|
144
|
+
"timed_out": False,
|
|
145
|
+
"stdout": result.stdout,
|
|
146
|
+
"stderr": result.stderr,
|
|
147
|
+
},
|
|
148
|
+
sort_keys=True,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
if result.returncode:
|
|
152
|
+
raise ValueError(f"docker {arguments[0]} failed: {result.stderr.strip()}")
|
|
153
|
+
return result.stdout
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _assert_daemon(expected_runtime: dict[str, object]) -> None:
|
|
157
|
+
engine = _object(expected_runtime.get("docker_engine"), "frozen Docker engine")
|
|
158
|
+
actual = _object(
|
|
159
|
+
json.loads(_docker("info", "--format", "{{json .}}", expected_runtime=expected_runtime)),
|
|
160
|
+
"Docker daemon",
|
|
161
|
+
)
|
|
162
|
+
if not engine.get("id") or actual.get("ID") != engine["id"]:
|
|
163
|
+
raise ValueError("Docker daemon changed; cannot accept export or cleanup proof")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _engine() -> tuple[dict[str, Any], str]:
|
|
167
|
+
info = _object(json.loads(_docker("info", "--format", "{{json .}}")), "Docker info")
|
|
168
|
+
contexts = json.loads(_docker("context", "inspect"))
|
|
169
|
+
if not isinstance(contexts, list) or len(contexts) != 1:
|
|
170
|
+
raise ValueError("Docker must have one selected context")
|
|
171
|
+
context = _object(contexts[0], "Docker context")
|
|
172
|
+
endpoints = _object(context.get("Endpoints"), "Docker endpoints")
|
|
173
|
+
docker = _object(endpoints.get("docker"), "Docker endpoint")
|
|
174
|
+
endpoint = (
|
|
175
|
+
docker.get("Host")
|
|
176
|
+
if os.environ.get("DOCKER_CONTEXT")
|
|
177
|
+
else os.environ.get("DOCKER_HOST") or docker.get("Host")
|
|
178
|
+
)
|
|
179
|
+
if not isinstance(endpoint, str) or not endpoint.startswith("unix://"):
|
|
180
|
+
raise ValueError("Harbor execution currently requires a local Unix Docker endpoint")
|
|
181
|
+
return info, endpoint
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _executable_identity(path: Path) -> dict[str, str]:
|
|
185
|
+
resolved = path.resolve(strict=True)
|
|
186
|
+
if not path.is_absolute() or not resolved.is_file() or not os.access(resolved, os.X_OK):
|
|
187
|
+
raise ValueError("Docker executables must be readable absolute executable files")
|
|
188
|
+
with resolved.open("rb") as binary:
|
|
189
|
+
digest = hashlib.file_digest(binary, "sha256").hexdigest()
|
|
190
|
+
return {"path": str(path), "resolved_path": str(resolved), "sha256": digest}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _docker_plugins(info: dict[str, Any]) -> dict[str, dict[str, str]]:
|
|
194
|
+
client = _object(info.get("ClientInfo"), "Docker client metadata")
|
|
195
|
+
plugins = client.get("Plugins")
|
|
196
|
+
if not isinstance(plugins, list):
|
|
197
|
+
raise ValueError("Docker did not report its CLI plugins")
|
|
198
|
+
result: dict[str, dict[str, str]] = {}
|
|
199
|
+
for name in ("compose", "buildx"):
|
|
200
|
+
selected = [item for item in plugins if isinstance(item, dict) and item.get("Name") == name]
|
|
201
|
+
if len(selected) != 1:
|
|
202
|
+
raise ValueError(f"Docker must report one usable {name} plugin")
|
|
203
|
+
plugin = selected[0]
|
|
204
|
+
path, version = plugin.get("Path"), plugin.get("Version")
|
|
205
|
+
if not isinstance(path, str) or not isinstance(version, str) or not version:
|
|
206
|
+
raise ValueError(f"Docker did not report the {name} executable and version")
|
|
207
|
+
result[name] = {**_executable_identity(Path(path)), "version": version}
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _docker_cli(info: dict[str, Any]) -> dict[str, str]:
|
|
212
|
+
client = _object(info.get("ClientInfo"), "Docker client metadata")
|
|
213
|
+
version, path = client.get("Version"), shutil.which("docker")
|
|
214
|
+
if not path or not isinstance(version, str) or not version:
|
|
215
|
+
raise ValueError("Docker did not report its client executable and version")
|
|
216
|
+
return {**_executable_identity(Path(path)), "version": version}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _frozen_executable(identity: dict[str, Any], label: str) -> dict[str, str]:
|
|
220
|
+
try:
|
|
221
|
+
actual = _executable_identity(Path(identity["path"]))
|
|
222
|
+
except (OSError, ValueError) as error:
|
|
223
|
+
raise isolation.RuntimeDriftError(f"Cannot verify frozen Docker {label}") from error
|
|
224
|
+
if any(identity.get(key) != value for key, value in actual.items()):
|
|
225
|
+
raise isolation.RuntimeDriftError(f"Docker {label} changed since preparation")
|
|
226
|
+
return actual
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _private_docker_config(
|
|
230
|
+
*, private: Path, env: dict[str, str], expected_runtime: dict[str, object]
|
|
231
|
+
) -> dict[str, object]:
|
|
232
|
+
"""Expose only frozen plugin binaries, never the operator's Docker settings."""
|
|
233
|
+
plugins = _object(expected_runtime.get("docker_plugins"), "frozen Docker plugins")
|
|
234
|
+
config = private / "docker"
|
|
235
|
+
directory = config / "cli-plugins"
|
|
236
|
+
directory.mkdir(parents=True, mode=0o700)
|
|
237
|
+
cli = _object(expected_runtime.get("docker_cli"), "frozen Docker CLI")
|
|
238
|
+
actual_cli = _frozen_executable(cli, "CLI")
|
|
239
|
+
binaries = private / "docker-bin"
|
|
240
|
+
binaries.mkdir(mode=0o700)
|
|
241
|
+
(binaries / "docker").symlink_to(actual_cli["resolved_path"])
|
|
242
|
+
env["PATH"] = str(binaries) + os.pathsep + env.get("PATH", os.defpath)
|
|
243
|
+
for name in ("compose", "buildx"):
|
|
244
|
+
plugin = _object(plugins.get(name), f"frozen {name} plugin")
|
|
245
|
+
actual = _frozen_executable(plugin, name + " plugin")
|
|
246
|
+
(directory / ("docker-" + name)).symlink_to(actual["resolved_path"])
|
|
247
|
+
env["DOCKER_CONFIG"] = str(config)
|
|
248
|
+
try:
|
|
249
|
+
selected_cli = shutil.which("docker", path=env["PATH"])
|
|
250
|
+
if selected_cli is None:
|
|
251
|
+
raise ValueError("Private Docker executable is unavailable")
|
|
252
|
+
observed_cli = _executable_identity(Path(selected_cli))
|
|
253
|
+
discovery = run_command(
|
|
254
|
+
["docker", "info", "--format", "{{json .ClientInfo}}"],
|
|
255
|
+
cwd=private,
|
|
256
|
+
env=env,
|
|
257
|
+
timeout=_DOCKER_TIMEOUT,
|
|
258
|
+
)
|
|
259
|
+
if discovery.returncode:
|
|
260
|
+
raise ValueError("Private Docker client discovery failed")
|
|
261
|
+
client = _object(json.loads(discovery.stdout), "isolated Docker client")
|
|
262
|
+
observed = _docker_plugins({"ClientInfo": client})
|
|
263
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
264
|
+
raise isolation.RuntimeDriftError(
|
|
265
|
+
"Cannot verify isolated Docker plugin discovery"
|
|
266
|
+
) from error
|
|
267
|
+
if (
|
|
268
|
+
client.get("Version") != cli["version"]
|
|
269
|
+
or any(observed_cli[key] != cli[key] for key in ("resolved_path", "sha256"))
|
|
270
|
+
or any(
|
|
271
|
+
observed[name][key] != plugins[name][key]
|
|
272
|
+
for name in ("compose", "buildx")
|
|
273
|
+
for key in ("resolved_path", "sha256", "version")
|
|
274
|
+
)
|
|
275
|
+
):
|
|
276
|
+
raise isolation.RuntimeDriftError(
|
|
277
|
+
"Isolated Docker executable identity differs from preparation"
|
|
278
|
+
)
|
|
279
|
+
for name in ("compose", "buildx"):
|
|
280
|
+
argv = ["docker", name, "version"] + (["--short"] if name == "compose" else [])
|
|
281
|
+
try:
|
|
282
|
+
result = run_command(argv, cwd=private, env=env, timeout=_DOCKER_TIMEOUT)
|
|
283
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
284
|
+
raise isolation.RuntimeDriftError(f"Cannot verify isolated Docker {name}") from error
|
|
285
|
+
version = plugins[name]["version"]
|
|
286
|
+
if result.returncode or version.removeprefix("v") not in {
|
|
287
|
+
token.removeprefix("v") for token in result.stdout.split()
|
|
288
|
+
}:
|
|
289
|
+
raise isolation.RuntimeDriftError(
|
|
290
|
+
f"Isolated Docker {name} differs from its frozen version"
|
|
291
|
+
)
|
|
292
|
+
return {
|
|
293
|
+
"docker_cli": {**observed_cli, "version": client["Version"]},
|
|
294
|
+
"docker_plugins": observed,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def runtime_identity(*, cpus: int = 1, memory_mb: int = 2048) -> dict[str, object]:
|
|
299
|
+
"""Fingerprint the framework, wrapper, selected daemon and enforced limits."""
|
|
300
|
+
try:
|
|
301
|
+
version = importlib.metadata.version("harbor")
|
|
302
|
+
except importlib.metadata.PackageNotFoundError as error:
|
|
303
|
+
raise ValueError(f"Harbor {HARBOR_VERSION} is required for new coding runs") from error
|
|
304
|
+
if version != HARBOR_VERSION:
|
|
305
|
+
raise ValueError(f"Harbor {HARBOR_VERSION} is required; installed version is {version}")
|
|
306
|
+
if cpus < 1 or memory_mb < 128:
|
|
307
|
+
raise ValueError("Container limits require at least one CPU and 128 MB memory")
|
|
308
|
+
info, endpoint = _engine()
|
|
309
|
+
required = ("ID", "ServerVersion", "OSType", "Architecture")
|
|
310
|
+
if not all(isinstance(info.get(key), str) and info[key] for key in required):
|
|
311
|
+
raise ValueError("Docker did not report a complete daemon identity")
|
|
312
|
+
if info["OSType"] != "linux":
|
|
313
|
+
raise ValueError("Harbor execution currently requires Linux containers")
|
|
314
|
+
capacity = {"cpus": info.get("NCPU"), "memory_bytes": info.get("MemTotal")}
|
|
315
|
+
if not all(isinstance(value, int) and value > 0 for value in capacity.values()):
|
|
316
|
+
raise ValueError("Docker did not report usable CPU and memory capacity")
|
|
317
|
+
return {
|
|
318
|
+
"protocol": PROTOCOL,
|
|
319
|
+
"harbor_version": version,
|
|
320
|
+
"submission_receipt_version": 2,
|
|
321
|
+
"codex_transport": {
|
|
322
|
+
"version": _CODEX_TRANSPORT_VERSION,
|
|
323
|
+
"harbor_run_sha256": _assert_codex_transport_source(),
|
|
324
|
+
},
|
|
325
|
+
"launcher_version": components.VERSIONS["launcher"],
|
|
326
|
+
"implementation_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
|
327
|
+
"docker_cli": _docker_cli(info),
|
|
328
|
+
"docker_plugins": _docker_plugins(info),
|
|
329
|
+
"docker_engine": {
|
|
330
|
+
"id": info["ID"],
|
|
331
|
+
"version": info["ServerVersion"],
|
|
332
|
+
"platform": info["OSType"] + "/" + info["Architecture"],
|
|
333
|
+
"endpoint": endpoint,
|
|
334
|
+
"capacity": capacity,
|
|
335
|
+
},
|
|
336
|
+
"resources": {
|
|
337
|
+
"cpus": cpus,
|
|
338
|
+
"memory_mb": memory_mb,
|
|
339
|
+
"cpu_enforcement": "limit",
|
|
340
|
+
"memory_enforcement": "limit",
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Saved for provenance and never compared: routine Docker and PluginBench updates change them.
|
|
346
|
+
_RECORDED_RUNTIME = (
|
|
347
|
+
("implementation_sha256",),
|
|
348
|
+
("docker_cli",),
|
|
349
|
+
("docker_plugins",),
|
|
350
|
+
("docker_engine", "id"),
|
|
351
|
+
("docker_engine", "version"),
|
|
352
|
+
("docker_engine", "endpoint"),
|
|
353
|
+
)
|
|
354
|
+
# Checked as a fit for the run's containers, never compared: Docker reports memory that jitters.
|
|
355
|
+
_FIT_RUNTIME = (("docker_engine", "capacity"),)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def runtime_contract(identity: dict[str, object], *, launcher: bool = True) -> dict[str, Any]:
|
|
359
|
+
"""The part of a runtime identity that must stay equal: all but recorded and fit fields.
|
|
360
|
+
|
|
361
|
+
Any field not listed above is compared, so a field added later is exact by default. A
|
|
362
|
+
runtime saved before declared versions gets its launcher version from its source hash,
|
|
363
|
+
and an unknown hash raises. `launcher=False` leaves the launcher version out, for work
|
|
364
|
+
that starts no coding agent.
|
|
365
|
+
"""
|
|
366
|
+
if not isinstance(identity, dict):
|
|
367
|
+
raise ValueError("a saved runtime identity is not an object")
|
|
368
|
+
contract = copy.deepcopy(identity)
|
|
369
|
+
version = contract.get("launcher_version")
|
|
370
|
+
if version is None:
|
|
371
|
+
version = components.saved_version(
|
|
372
|
+
"launcher", {"harbor_backend.py": contract.get("implementation_sha256")}
|
|
373
|
+
)
|
|
374
|
+
if launcher:
|
|
375
|
+
contract["launcher_version"] = version
|
|
376
|
+
else:
|
|
377
|
+
contract.pop("launcher_version", None)
|
|
378
|
+
for *parents, field in _RECORDED_RUNTIME + _FIT_RUNTIME:
|
|
379
|
+
node: object = contract
|
|
380
|
+
for key in parents:
|
|
381
|
+
node = node.get(key) if isinstance(node, dict) else None
|
|
382
|
+
if isinstance(node, dict):
|
|
383
|
+
node.pop(field, None)
|
|
384
|
+
return contract
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def runtime_changes(saved: dict[str, Any], current: dict[str, Any], prefix: str = "") -> list[str]:
|
|
388
|
+
"""Each differing field of two runtime contracts, by dotted path, with both values."""
|
|
389
|
+
changes = []
|
|
390
|
+
for key in sorted(saved.keys() | current.keys()):
|
|
391
|
+
before, after = saved.get(key, _ABSENT), current.get(key, _ABSENT)
|
|
392
|
+
if isinstance(before, dict) and isinstance(after, dict):
|
|
393
|
+
changes.extend(runtime_changes(before, after, prefix + key + "."))
|
|
394
|
+
elif before != after:
|
|
395
|
+
changes.append(f"{prefix}{key} was {_shown(before)}, is now {_shown(after)}")
|
|
396
|
+
return changes
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
_ABSENT = object()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _shown(value: object) -> str:
|
|
403
|
+
return "absent" if value is _ABSENT else json.dumps(value, sort_keys=True)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _fitting_jobs(capacity: dict[str, Any], *, cpus: int, memory_mb: int) -> int:
|
|
407
|
+
"""How many containers of these limits Docker can hold, keeping 20% of memory free."""
|
|
408
|
+
return min(
|
|
409
|
+
int(capacity["cpus"]) // cpus,
|
|
410
|
+
int(int(capacity["memory_bytes"]) * 0.8) // (memory_mb * 1024**2),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def assert_runtime(
|
|
415
|
+
expected: dict[str, object], *, jobs: int = 1, launcher: bool = True
|
|
416
|
+
) -> dict[str, object]:
|
|
417
|
+
"""Check the current runtime against a saved one, and return the current one.
|
|
418
|
+
|
|
419
|
+
Exact fields must be equal, Docker capacity must hold `jobs` containers, and recorded
|
|
420
|
+
fields may differ. Docker commands after this check use the returned identity, so an
|
|
421
|
+
updated Docker CLI or a restarted engine runs with what is installed now.
|
|
422
|
+
"""
|
|
423
|
+
resources = _object(expected.get("resources"), "container resources")
|
|
424
|
+
cpus, memory = resources.get("cpus"), resources.get("memory_mb")
|
|
425
|
+
if not isinstance(cpus, int) or not isinstance(memory, int):
|
|
426
|
+
raise ValueError("Frozen container resource limits are missing")
|
|
427
|
+
saved = runtime_contract(expected, launcher=launcher)
|
|
428
|
+
try:
|
|
429
|
+
actual = runtime_identity(cpus=cpus, memory_mb=memory)
|
|
430
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
431
|
+
raise isolation.RuntimeDriftError(
|
|
432
|
+
"Cannot verify the frozen Harbor and Docker runtime"
|
|
433
|
+
) from error
|
|
434
|
+
changes = runtime_changes(saved, runtime_contract(actual, launcher=launcher))
|
|
435
|
+
if changes:
|
|
436
|
+
raise isolation.RuntimeDriftError(
|
|
437
|
+
"Harbor or Docker runtime changed since this run was prepared: "
|
|
438
|
+
+ "; ".join(changes)
|
|
439
|
+
+ ". Restore that runtime, or start a new run"
|
|
440
|
+
)
|
|
441
|
+
current = _object(_object(actual["docker_engine"], "Docker engine")["capacity"], "capacity")
|
|
442
|
+
if _fitting_jobs(current, cpus=cpus, memory_mb=memory) < jobs:
|
|
443
|
+
engine = expected.get("docker_engine")
|
|
444
|
+
before = engine.get("capacity") if isinstance(engine, dict) else None
|
|
445
|
+
raise isolation.RuntimeDriftError(
|
|
446
|
+
f"Docker capacity no longer fits this run: {jobs} jobs of {cpus} CPUs and "
|
|
447
|
+
f"{memory} MB need {jobs * cpus} CPUs and {jobs * memory} MB within 80% of "
|
|
448
|
+
f"memory; docker_engine.capacity was {_shown(before)}, is now {_shown(current)}. "
|
|
449
|
+
"Give Docker that capacity again, or start a new run"
|
|
450
|
+
)
|
|
451
|
+
return actual
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def assert_capacity(inputs: list[list[dict[str, object]]], *, jobs: int) -> None:
|
|
455
|
+
"""Check that Docker holds the containers that run at once: `jobs` of `inputs`.
|
|
456
|
+
|
|
457
|
+
Each input runs its containers one after another, so it needs its largest CPU and its
|
|
458
|
+
largest memory limit. The largest needs of the inputs that run at once are summed for
|
|
459
|
+
each resource separately, so different limits are never under-counted. Measurement uses
|
|
460
|
+
this; it starts no coding agent.
|
|
461
|
+
"""
|
|
462
|
+
needs = []
|
|
463
|
+
for runtimes in inputs:
|
|
464
|
+
limits = []
|
|
465
|
+
for runtime in runtimes:
|
|
466
|
+
resources = _object(runtime.get("resources"), "container resources")
|
|
467
|
+
cpus, memory = resources.get("cpus"), resources.get("memory_mb")
|
|
468
|
+
if not isinstance(cpus, int) or not isinstance(memory, int):
|
|
469
|
+
raise ValueError("Frozen container resource limits are missing")
|
|
470
|
+
limits.append((cpus, memory))
|
|
471
|
+
needs.append((max(limit[0] for limit in limits), max(limit[1] for limit in limits)))
|
|
472
|
+
count = min(jobs, len(needs))
|
|
473
|
+
cpus = sum(sorted((need[0] for need in needs), reverse=True)[:count])
|
|
474
|
+
memory = sum(sorted((need[1] for need in needs), reverse=True)[:count])
|
|
475
|
+
actual = assert_runtime(inputs[0][0], launcher=False)
|
|
476
|
+
current = _object(_object(actual["docker_engine"], "Docker engine")["capacity"], "capacity")
|
|
477
|
+
if cpus > int(current["cpus"]) or memory * 1024**2 > int(current["memory_bytes"]) * 0.8:
|
|
478
|
+
raise isolation.RuntimeDriftError(
|
|
479
|
+
f"Docker capacity no longer fits this run's measurement: {count} containers at once "
|
|
480
|
+
f"need {cpus} CPUs and {memory} MB within 80% of memory; docker_engine.capacity is "
|
|
481
|
+
f"now {_shown(current)}. Give Docker that capacity again, then resume the run"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def capacity_jobs(*, cpus: int = 1, memory_mb: int = 2048) -> int:
|
|
486
|
+
"""Bound coding concurrency by the actual Docker allocation, leaving headroom."""
|
|
487
|
+
if cpus < 1 or memory_mb < 128:
|
|
488
|
+
raise ValueError("Container limits require at least one CPU and 128 MB memory")
|
|
489
|
+
info, _ = _engine()
|
|
490
|
+
cpu_capacity, memory_capacity = info.get("NCPU"), info.get("MemTotal")
|
|
491
|
+
if not isinstance(cpu_capacity, int) or not isinstance(memory_capacity, int):
|
|
492
|
+
raise ValueError("Docker did not report CPU and memory capacity")
|
|
493
|
+
jobs = min(
|
|
494
|
+
4,
|
|
495
|
+
_fitting_jobs(
|
|
496
|
+
{"cpus": cpu_capacity, "memory_bytes": memory_capacity}, cpus=cpus, memory_mb=memory_mb
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
if jobs < 1:
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"One coding container ({cpus} CPUs, {memory_mb} MB) does not fit within Docker capacity ({cpu_capacity} CPUs, {memory_capacity // 1024**2} MB; 80% memory budget)"
|
|
502
|
+
)
|
|
503
|
+
return jobs
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def preflight(*, jobs: int = 1, cpus: int = 1, memory_mb: int = 2048) -> dict[str, Any]:
|
|
507
|
+
"""Check local capabilities and capacity without installing or calling a model."""
|
|
508
|
+
runtime = runtime_identity(cpus=cpus, memory_mb=memory_mb)
|
|
509
|
+
info, _ = _engine()
|
|
510
|
+
if jobs < 1 or jobs * cpus > info.get("NCPU", 0):
|
|
511
|
+
raise ValueError("Requested concurrent container CPU limits exceed Docker capacity")
|
|
512
|
+
if jobs * memory_mb * 1024**2 > info.get("MemTotal", 0) * 0.8:
|
|
513
|
+
raise ValueError("Requested concurrent container memory exceeds 80% of Docker capacity")
|
|
514
|
+
compose = _docker("compose", "version", "--short").strip()
|
|
515
|
+
return {"runtime": runtime, "compose_version": compose, "paid_calls": 0}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _inspect_image(
|
|
519
|
+
image: str, *, pull: bool, expected_runtime: dict[str, object] | None = None
|
|
520
|
+
) -> dict[str, Any]:
|
|
521
|
+
try:
|
|
522
|
+
inspected = _docker("image", "inspect", image, expected_runtime=expected_runtime)
|
|
523
|
+
except ValueError as error:
|
|
524
|
+
if not pull or "no such image" not in str(error).lower():
|
|
525
|
+
raise
|
|
526
|
+
_docker("pull", image, timeout=600, expected_runtime=expected_runtime)
|
|
527
|
+
inspected = _docker("image", "inspect", image, expected_runtime=expected_runtime)
|
|
528
|
+
images = json.loads(inspected)
|
|
529
|
+
if not isinstance(images, list) or len(images) != 1:
|
|
530
|
+
raise ValueError("Docker did not identify one image")
|
|
531
|
+
return _object(images[0], "Docker image")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def image_identity(
|
|
535
|
+
image: str, *, pull: bool = False, expected_runtime: dict[str, object] | None = None
|
|
536
|
+
) -> dict[str, str]:
|
|
537
|
+
"""Bind an immutable base image to the local platform that will run it."""
|
|
538
|
+
if re.fullmatch(r"[^\s]+@sha256:[0-9a-f]{64}", image) is None:
|
|
539
|
+
raise ValueError("Agent images must use an immutable sha256 registry digest")
|
|
540
|
+
image_info = _inspect_image(image, pull=pull, expected_runtime=expected_runtime)
|
|
541
|
+
config = _object(image_info.get("Config"), "Docker image config")
|
|
542
|
+
workdir = config.get("WorkingDir")
|
|
543
|
+
if not isinstance(workdir, str) or re.fullmatch(r"/[A-Za-z0-9_-]+", workdir) is None:
|
|
544
|
+
raise ValueError("Base image must have one explicit project directory below /")
|
|
545
|
+
if workdir in {"/root", "/home", "/tmp", "/usr", "/bin", "/etc", "/var", "/logs"}:
|
|
546
|
+
raise ValueError(f"Unsupported base-image project directory: {workdir}")
|
|
547
|
+
return {
|
|
548
|
+
"image": image,
|
|
549
|
+
"image_id": str(image_info["Id"]),
|
|
550
|
+
"platform": str(image_info["Os"]) + "/" + str(image_info["Architecture"]),
|
|
551
|
+
"workdir": workdir,
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def resolve_image(image: str) -> dict[str, str]:
|
|
556
|
+
"""Resolve a task's image tag once during preparation, then freeze its digest."""
|
|
557
|
+
if not image or any(character.isspace() for character in image):
|
|
558
|
+
raise ValueError("Task image must be a nonempty Docker image reference")
|
|
559
|
+
if "@" in image:
|
|
560
|
+
return {**image_identity(image, pull=True), "original_image": image}
|
|
561
|
+
original = _inspect_image(image, pull=True)
|
|
562
|
+
repository = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image
|
|
563
|
+
digests = original.get("RepoDigests")
|
|
564
|
+
if not isinstance(digests, list):
|
|
565
|
+
raise ValueError("Task image has no immutable repository digest")
|
|
566
|
+
matches = sorted(
|
|
567
|
+
digest
|
|
568
|
+
for digest in digests
|
|
569
|
+
if isinstance(digest, str) and digest.startswith(repository + "@sha256:")
|
|
570
|
+
)
|
|
571
|
+
if not matches:
|
|
572
|
+
raise ValueError("Task image has no immutable digest for the requested repository")
|
|
573
|
+
resolved = image_identity(matches[0])
|
|
574
|
+
if resolved["image_id"] != original.get("Id"):
|
|
575
|
+
raise ValueError("Task image changed while resolving its immutable digest")
|
|
576
|
+
return {**resolved, "original_image": image}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _normalize_version(version: str) -> str:
|
|
580
|
+
return version.removeprefix("codex-cli ").split()[0]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _required_tools(harness: str) -> str:
|
|
584
|
+
support = "rg" if harness == "codex" else "ps"
|
|
585
|
+
return f'for tool in {_BASE_TOOLS} {support}; do command -v "$tool" >/dev/null; done'
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _cli_guard(harness: str, version: str) -> str:
|
|
589
|
+
if (
|
|
590
|
+
harness not in {"codex", "claude"}
|
|
591
|
+
or re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?", version) is None
|
|
592
|
+
):
|
|
593
|
+
raise ValueError("Toolchains require a supported harness and exact CLI version")
|
|
594
|
+
tools = _required_tools(harness) + "; "
|
|
595
|
+
if harness == "codex":
|
|
596
|
+
return tools + f'test "$(codex --version)" = "codex-cli {version}"'
|
|
597
|
+
return tools + f'actual="$(claude --version)"; test "${{actual%% *}}" = "{version}"'
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def run_trial(config: dict[str, Any]) -> dict[str, Any]:
|
|
601
|
+
"""Run one native trial, without queue retries or Harbor resume behavior."""
|
|
602
|
+
# Harbor's wheel is untyped. Keep its dynamic API at this single boundary,
|
|
603
|
+
# without making reporting import its execution dependencies.
|
|
604
|
+
config = dict(config)
|
|
605
|
+
boundary = config.pop("pluginbench_boundary", None)
|
|
606
|
+
agent = _object(config.get("agent"), "native agent configuration")
|
|
607
|
+
separate_codex = agent.get("name") == "codex" and config.get("source_trial") is None
|
|
608
|
+
if separate_codex:
|
|
609
|
+
_assert_codex_transport_source()
|
|
610
|
+
trial_config = importlib.import_module("harbor.models.trial.config").TrialConfig
|
|
611
|
+
trial_type = importlib.import_module("harbor.trial.trial").Trial
|
|
612
|
+
trial = await trial_type.create(trial_config.model_validate(config))
|
|
613
|
+
if boundary is not None:
|
|
614
|
+
install_native_hooks(trial, _native_object(boundary, "native boundary"))
|
|
615
|
+
if separate_codex:
|
|
616
|
+
original_exec = trial.agent.exec_as_agent
|
|
617
|
+
treatment_installed = False
|
|
618
|
+
|
|
619
|
+
async def separated_exec(
|
|
620
|
+
environment: object,
|
|
621
|
+
command: str,
|
|
622
|
+
env: dict[str, str] | None = None,
|
|
623
|
+
cwd: str | None = None,
|
|
624
|
+
timeout_sec: int | None = None,
|
|
625
|
+
) -> object:
|
|
626
|
+
nonlocal treatment_installed
|
|
627
|
+
if "codex exec" in command:
|
|
628
|
+
if not command.startswith(_CODEX_EXEC_PREFIX) or not command.endswith(
|
|
629
|
+
_CODEX_EXEC_SUFFIX
|
|
630
|
+
):
|
|
631
|
+
raise ValueError(
|
|
632
|
+
"Native Codex invocation differs from the pinned transport contract"
|
|
633
|
+
)
|
|
634
|
+
if boundary is not None and "treatment" in boundary:
|
|
635
|
+
if treatment_installed:
|
|
636
|
+
raise ValueError("native treatment was already used; use a fresh attempt")
|
|
637
|
+
if env is None:
|
|
638
|
+
raise ValueError("native coding command has no private environment")
|
|
639
|
+
await native_treatments.install(
|
|
640
|
+
environment=environment,
|
|
641
|
+
treatment=Treatment.model_validate(boundary["treatment"]),
|
|
642
|
+
env=env,
|
|
643
|
+
cwd=str(boundary["image"]["workdir"]),
|
|
644
|
+
artifacts=Path(str(boundary["area"])) / "artifacts",
|
|
645
|
+
)
|
|
646
|
+
treatment_installed = True
|
|
647
|
+
command = command.removesuffix(_CODEX_EXEC_SUFFIX) + (
|
|
648
|
+
"2>/logs/agent/codex.stderr.log </dev/null | tee /logs/agent/codex.txt"
|
|
649
|
+
)
|
|
650
|
+
result: object = await original_exec(
|
|
651
|
+
environment,
|
|
652
|
+
command,
|
|
653
|
+
env=env,
|
|
654
|
+
cwd=cwd,
|
|
655
|
+
timeout_sec=timeout_sec,
|
|
656
|
+
)
|
|
657
|
+
return result
|
|
658
|
+
|
|
659
|
+
trial.agent.exec_as_agent = separated_exec
|
|
660
|
+
result = await trial.run()
|
|
661
|
+
return {
|
|
662
|
+
"result": result.model_dump(mode="json"),
|
|
663
|
+
"session_id": trial.agent_environment.session_id,
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _project_ids(kind: str, project: str, expected_runtime: dict[str, object]) -> list[str]:
|
|
668
|
+
arguments = [kind, "ls", "--quiet"] if kind != "container" else ["ps", "-aq"]
|
|
669
|
+
output = _docker(
|
|
670
|
+
*arguments,
|
|
671
|
+
"--filter",
|
|
672
|
+
f"label=com.docker.compose.project={project}",
|
|
673
|
+
expected_runtime=expected_runtime,
|
|
674
|
+
)
|
|
675
|
+
return output.split()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def stopped_container_state(value: object) -> dict[str, object]:
|
|
679
|
+
"""Retain engine failure evidence independently of native agent completion."""
|
|
680
|
+
state = _object(value, "container state")
|
|
681
|
+
if state.get("Running") is not False or state.get("Status") not in {"exited", "dead"}:
|
|
682
|
+
raise ValueError("Submission container is not stopped; refusing a live snapshot")
|
|
683
|
+
if (
|
|
684
|
+
type(state.get("OOMKilled")) is not bool
|
|
685
|
+
or type(state.get("ExitCode")) is not int
|
|
686
|
+
or state["ExitCode"] < 0
|
|
687
|
+
):
|
|
688
|
+
raise ValueError("Docker did not report complete stopped-container failure evidence")
|
|
689
|
+
if not all(isinstance(state.get(key), str) for key in ("Error", "StartedAt", "FinishedAt")):
|
|
690
|
+
raise ValueError("Docker did not report stopped-container error and timestamps")
|
|
691
|
+
return {
|
|
692
|
+
key: state[key]
|
|
693
|
+
for key in (
|
|
694
|
+
"Running",
|
|
695
|
+
"Status",
|
|
696
|
+
"OOMKilled",
|
|
697
|
+
"ExitCode",
|
|
698
|
+
"Error",
|
|
699
|
+
"StartedAt",
|
|
700
|
+
"FinishedAt",
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def cleanup(*, project: str, owner: str, expected_runtime: dict[str, object]) -> dict[str, object]:
|
|
706
|
+
"""Remove only this trial's labelled resources and verify that none remain."""
|
|
707
|
+
_assert_daemon(expected_runtime)
|
|
708
|
+
containers = _project_ids("container", project, expected_runtime)
|
|
709
|
+
if containers:
|
|
710
|
+
_docker("rm", "--force", "--volumes", *containers, expected_runtime=expected_runtime)
|
|
711
|
+
networks = _project_ids("network", project, expected_runtime)
|
|
712
|
+
if networks:
|
|
713
|
+
_docker("network", "rm", *networks, expected_runtime=expected_runtime)
|
|
714
|
+
images = _docker(
|
|
715
|
+
"image",
|
|
716
|
+
"ls",
|
|
717
|
+
"-q",
|
|
718
|
+
"--filter",
|
|
719
|
+
f"label={_OWNER_LABEL}={owner}",
|
|
720
|
+
expected_runtime=expected_runtime,
|
|
721
|
+
).split()
|
|
722
|
+
if images:
|
|
723
|
+
_docker("image", "rm", *images, expected_runtime=expected_runtime)
|
|
724
|
+
residuals = {
|
|
725
|
+
"containers": _project_ids("container", project, expected_runtime),
|
|
726
|
+
"networks": _project_ids("network", project, expected_runtime),
|
|
727
|
+
"images": _docker(
|
|
728
|
+
"image",
|
|
729
|
+
"ls",
|
|
730
|
+
"-q",
|
|
731
|
+
"--filter",
|
|
732
|
+
f"label={_OWNER_LABEL}={owner}",
|
|
733
|
+
expected_runtime=expected_runtime,
|
|
734
|
+
).split(),
|
|
735
|
+
}
|
|
736
|
+
if any(residuals.values()):
|
|
737
|
+
raise ValueError(f"Owned Harbor resources remain after cleanup: {residuals}")
|
|
738
|
+
_assert_daemon(expected_runtime)
|
|
739
|
+
return {"status": "complete", "residuals": residuals}
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _native_evidence(
|
|
743
|
+
*,
|
|
744
|
+
area: Path,
|
|
745
|
+
host: str,
|
|
746
|
+
effort: str,
|
|
747
|
+
transport: str | None = None,
|
|
748
|
+
include_descendants: bool = False,
|
|
749
|
+
) -> dict[str, Any]:
|
|
750
|
+
codex.validate_artifacts(area / "artifacts")
|
|
751
|
+
agent = area / "artifacts/harbor/trial/agent"
|
|
752
|
+
raw = agent / ("codex.txt" if host == "codex" else "claude-code.txt")
|
|
753
|
+
|
|
754
|
+
stdout = raw.read_text(errors="replace") if raw.is_file() else ""
|
|
755
|
+
sessions = agent / "sessions"
|
|
756
|
+
traces = [path.read_text(errors="replace") for path in sorted(sessions.rglob("*.jsonl"))]
|
|
757
|
+
artifacts = area / "artifacts"
|
|
758
|
+
if host == "codex":
|
|
759
|
+
if transport != _CODEX_TRANSPORT_VERSION:
|
|
760
|
+
raise ValueError("Unsupported frozen Codex transport version")
|
|
761
|
+
stderr = agent / "codex.stderr.log"
|
|
762
|
+
if not raw.is_file() or not stderr.is_file():
|
|
763
|
+
raise ValueError("Native Codex separate stdout/stderr capture is incomplete")
|
|
764
|
+
shutil.copyfile(stderr, artifacts / "codex.stderr.log")
|
|
765
|
+
(artifacts / "stdout.jsonl").write_text(stdout)
|
|
766
|
+
for index, trace in enumerate(traces):
|
|
767
|
+
(artifacts / f"host-trace-{index}.jsonl").write_text(trace)
|
|
768
|
+
return codex.parse_events(
|
|
769
|
+
stdout, traces, host, requested_effort=effort, include_descendants=include_descendants
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _native_object(value: object, label: str) -> dict[str, object]:
|
|
774
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
775
|
+
raise ValueError(f"{label} must be an object")
|
|
776
|
+
return {key: item for key, item in value.items()}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _portable_mode(path: Path) -> int:
|
|
780
|
+
"""Git tracks only the executable bit, so group and other bits depend on the checkout's umask."""
|
|
781
|
+
return 0o755 if path.stat().st_mode & 0o100 else 0o644
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _native_asset_inventory(task_dir: Path, *, exclude: str | None = None) -> dict[str, str]:
|
|
785
|
+
files: dict[str, str] = {}
|
|
786
|
+
if task_dir.is_symlink() or not task_dir.is_dir():
|
|
787
|
+
raise ValueError("Native task must be a directory, not a link")
|
|
788
|
+
for path in sorted(task_dir.rglob("*")):
|
|
789
|
+
if path.is_symlink() or not (path.is_dir() or path.is_file()):
|
|
790
|
+
raise ValueError(f"Unsupported native task asset: {path}")
|
|
791
|
+
if path.relative_to(task_dir).as_posix() == exclude:
|
|
792
|
+
continue
|
|
793
|
+
if path.is_file():
|
|
794
|
+
files[path.relative_to(task_dir).as_posix()] = hashlib.sha256(
|
|
795
|
+
str(_portable_mode(path)).encode() + b"\0" + path.read_bytes()
|
|
796
|
+
).hexdigest()
|
|
797
|
+
else:
|
|
798
|
+
files[path.relative_to(task_dir).as_posix() + "/"] = "directory:" + str(0o755)
|
|
799
|
+
return files
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def native_task_assets(task_dir: Path) -> str:
|
|
803
|
+
"""Bind all asset bytes, directories and modes without following links."""
|
|
804
|
+
return hashlib.sha256(
|
|
805
|
+
json.dumps(_native_asset_inventory(task_dir), sort_keys=True).encode()
|
|
806
|
+
).hexdigest()
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def validate_native_task(task_dir: Path) -> dict[str, object]:
|
|
810
|
+
"""Admit standard tasks by execution topology, never by task name."""
|
|
811
|
+
native_task_assets(task_dir)
|
|
812
|
+
task = importlib.import_module("harbor.models.task.task").Task(task_dir)
|
|
813
|
+
modes = importlib.import_module("harbor.models.task.verifier_mode")
|
|
814
|
+
if task.has_steps or modes.resolve_task_verifier_mode(task.config).value != "separate":
|
|
815
|
+
raise ValueError("Native execution requires a single-step task with a separate verifier")
|
|
816
|
+
definition = modes.resolve_verifier_environment_definition(task.config, task.paths)
|
|
817
|
+
if definition is None:
|
|
818
|
+
raise ValueError("Native separate verifier definition is missing")
|
|
819
|
+
for directory, configuration in (
|
|
820
|
+
(task.paths.environment_dir, task.config.environment),
|
|
821
|
+
(definition.directory, definition.config),
|
|
822
|
+
):
|
|
823
|
+
if configuration.os.value != "linux" or configuration.gpus or configuration.tpu:
|
|
824
|
+
raise ValueError("Native execution supports Linux CPU tasks only")
|
|
825
|
+
if any(
|
|
826
|
+
key in configuration.env
|
|
827
|
+
for key in ("OPENAI_BASE_URL", "ANTHROPIC_BASE_URL", "CODEX_HOME", "CLAUDE_CONFIG_DIR")
|
|
828
|
+
):
|
|
829
|
+
raise ValueError(
|
|
830
|
+
"Custom native provider endpoints or agent configuration directories are unsupported"
|
|
831
|
+
)
|
|
832
|
+
if (directory / "docker-compose.yaml").exists() or (
|
|
833
|
+
directory / "docker-compose.yml"
|
|
834
|
+
).exists():
|
|
835
|
+
raise ValueError(
|
|
836
|
+
"Task-authored Docker Compose is unsupported: host mounts and service topology are not admitted"
|
|
837
|
+
)
|
|
838
|
+
if not configuration.docker_image and not (directory / "Dockerfile").is_file():
|
|
839
|
+
raise ValueError("Native environment needs a Docker image or Dockerfile")
|
|
840
|
+
image = task.config.environment.docker_image
|
|
841
|
+
dockerfile = task.paths.environment_dir / "Dockerfile"
|
|
842
|
+
return {
|
|
843
|
+
"task_assets_sha256": native_task_assets(task_dir),
|
|
844
|
+
# Agents run in a prebuilt image as published; a Dockerfile beside it is never built.
|
|
845
|
+
"coding_image": {
|
|
846
|
+
"source": "prebuilt" if image else "dockerfile",
|
|
847
|
+
"image": image,
|
|
848
|
+
"ignored_dockerfile": str(dockerfile.relative_to(task_dir))
|
|
849
|
+
if image and dockerfile.is_file()
|
|
850
|
+
else None,
|
|
851
|
+
},
|
|
852
|
+
"configuration": task.config.model_dump(mode="json"),
|
|
853
|
+
"verifier_directory": str(definition.directory),
|
|
854
|
+
"verifier_bundled_tests": definition.bundled_tests,
|
|
855
|
+
"verifier_configuration": definition.config.model_dump(mode="json"),
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _native_image(
|
|
860
|
+
*, directory: Path, image: str | None, expected: dict[str, object]
|
|
861
|
+
) -> dict[str, str]:
|
|
862
|
+
if image:
|
|
863
|
+
description = _inspect_image(image, pull=True, expected_runtime=expected)
|
|
864
|
+
else:
|
|
865
|
+
description = _build_native_image(directory=directory, expected=expected)
|
|
866
|
+
image_id = description.get("Id")
|
|
867
|
+
if not isinstance(image_id, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", image_id) is None:
|
|
868
|
+
raise ValueError("Native environment did not resolve to an immutable image ID")
|
|
869
|
+
config = _native_object(description.get("Config"), "native image config")
|
|
870
|
+
return {
|
|
871
|
+
"image": _native_image_reference(image_id=image_id, expected=expected),
|
|
872
|
+
"image_id": image_id,
|
|
873
|
+
"platform": str(description["Os"]) + "/" + str(description["Architecture"]),
|
|
874
|
+
"workdir": str(config.get("WorkingDir") or "/"),
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _build_native_image(*, directory: Path, expected: dict[str, object]) -> dict[str, object]:
|
|
879
|
+
tag = "pluginbench-native-build:" + uuid4().hex
|
|
880
|
+
try:
|
|
881
|
+
_inspect_image(tag, pull=False, expected_runtime=expected)
|
|
882
|
+
except ValueError as error:
|
|
883
|
+
if "no such image" not in str(error).lower():
|
|
884
|
+
raise
|
|
885
|
+
else:
|
|
886
|
+
raise ValueError("Native temporary build reference already exists")
|
|
887
|
+
image_id: str | None = None
|
|
888
|
+
failure: BaseException | None = None
|
|
889
|
+
try:
|
|
890
|
+
# Tagged export loads an inspectable image; raw quiet output may be a config digest.
|
|
891
|
+
_docker(
|
|
892
|
+
"build",
|
|
893
|
+
"--quiet",
|
|
894
|
+
"--provenance=false",
|
|
895
|
+
"--tag",
|
|
896
|
+
tag,
|
|
897
|
+
str(directory),
|
|
898
|
+
timeout=1800,
|
|
899
|
+
expected_runtime=expected,
|
|
900
|
+
)
|
|
901
|
+
description = _native_object(
|
|
902
|
+
_inspect_image(tag, pull=False, expected_runtime=expected), "built native image"
|
|
903
|
+
)
|
|
904
|
+
candidate = description.get("Id")
|
|
905
|
+
if (
|
|
906
|
+
not isinstance(candidate, str)
|
|
907
|
+
or re.fullmatch(r"sha256:[0-9a-f]{64}", candidate) is None
|
|
908
|
+
):
|
|
909
|
+
raise ValueError("Native build did not expose its immutable image ID")
|
|
910
|
+
image_id = candidate
|
|
911
|
+
_native_image_reference(image_id=image_id, expected=expected)
|
|
912
|
+
return description
|
|
913
|
+
except BaseException as error:
|
|
914
|
+
failure = error
|
|
915
|
+
raise
|
|
916
|
+
finally:
|
|
917
|
+
try:
|
|
918
|
+
remaining = _inspect_image(tag, pull=False, expected_runtime=expected)
|
|
919
|
+
except ValueError as error:
|
|
920
|
+
if "no such image" not in str(error).lower():
|
|
921
|
+
if failure is None:
|
|
922
|
+
raise
|
|
923
|
+
failure.add_note("Native build cleanup inspection failed: " + str(error))
|
|
924
|
+
else:
|
|
925
|
+
try:
|
|
926
|
+
if image_id is None or remaining.get("Id") != image_id:
|
|
927
|
+
raise ValueError(
|
|
928
|
+
"Cannot prove temporary native build ownership; reference retained: " + tag
|
|
929
|
+
)
|
|
930
|
+
_native_image_reference(image_id=image_id, expected=expected)
|
|
931
|
+
_docker("image", "rm", "--no-prune", tag, expected_runtime=expected)
|
|
932
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
933
|
+
if failure is None:
|
|
934
|
+
raise
|
|
935
|
+
failure.add_note("Native build cleanup incomplete: " + str(error))
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _native_image_reference(*, image_id: str, expected: dict[str, object]) -> str:
|
|
939
|
+
"""BuildKit needs a named local reference; verify its immutable target."""
|
|
940
|
+
if re.fullmatch(r"sha256:[0-9a-f]{64}", image_id) is None:
|
|
941
|
+
raise ValueError("Native image must have a complete immutable ID")
|
|
942
|
+
reference = "pluginbench-native:sha256-" + image_id.removeprefix("sha256:")
|
|
943
|
+
try:
|
|
944
|
+
image = _inspect_image(reference, pull=False, expected_runtime=expected)
|
|
945
|
+
except ValueError as error:
|
|
946
|
+
if "no such image" not in str(error).lower():
|
|
947
|
+
raise
|
|
948
|
+
_docker("tag", image_id, reference, expected_runtime=expected)
|
|
949
|
+
image = _inspect_image(reference, pull=False, expected_runtime=expected)
|
|
950
|
+
if image.get("Id") != image_id:
|
|
951
|
+
raise ValueError("Native image reference was rebound to different content")
|
|
952
|
+
return reference
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _bundle_native_tests(
|
|
956
|
+
*, tests: Path, grader: dict[str, str], expected: dict[str, object]
|
|
957
|
+
) -> dict[str, str]:
|
|
958
|
+
"""Move the native verifier's exact uploaded tests into its frozen image."""
|
|
959
|
+
with tempfile.TemporaryDirectory(prefix="pluginbench-native-tests-") as temporary:
|
|
960
|
+
context = Path(temporary)
|
|
961
|
+
shutil.copytree(tests, context / "tests")
|
|
962
|
+
(context / "Dockerfile").write_text("FROM " + grader["image"] + "\nCOPY tests/ /tests/\n")
|
|
963
|
+
return _native_image(directory=context, image=None, expected=expected)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _assert_native_image(identity: dict[str, object], expected: dict[str, object]) -> None:
|
|
967
|
+
for reference in (identity["image_id"], identity["image"]):
|
|
968
|
+
if (
|
|
969
|
+
_inspect_image(str(reference), pull=False, expected_runtime=expected).get("Id")
|
|
970
|
+
!= identity["image_id"]
|
|
971
|
+
):
|
|
972
|
+
raise ValueError("Frozen native image is unavailable or its reference was rebound")
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def engine_id(runtime: object) -> object:
|
|
976
|
+
engine = runtime.get("docker_engine") if isinstance(runtime, dict) else None
|
|
977
|
+
return engine.get("id") if isinstance(engine, dict) else None
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def assert_prepared_images(
|
|
981
|
+
identity: dict[str, object], runtime: dict[str, object], *, label: str
|
|
982
|
+
) -> None:
|
|
983
|
+
"""Check that the current engine holds a preparation's images; name both engines if not."""
|
|
984
|
+
for name in ("image_identity", "grader_image_identity"):
|
|
985
|
+
image = _native_object(identity[name], "prepared native image")
|
|
986
|
+
try:
|
|
987
|
+
_assert_native_image(image, runtime)
|
|
988
|
+
except ValueError as error:
|
|
989
|
+
raise ValueError(
|
|
990
|
+
f"{label} was prepared on Docker engine {engine_id(identity.get('isolation'))}; "
|
|
991
|
+
f"the current engine {engine_id(runtime)} does not hold its image "
|
|
992
|
+
f"{image['image_id']}. Restore that engine's images, or start a new run"
|
|
993
|
+
) from error
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _native_container(
|
|
997
|
+
*, project: str, trial_dir: Path, expected: dict[str, object], image_id: str | None = None
|
|
998
|
+
) -> dict[str, object]:
|
|
999
|
+
_assert_daemon(expected)
|
|
1000
|
+
ids = _project_ids("container", project, expected)
|
|
1001
|
+
descriptions = json.loads(_docker("inspect", *ids, expected_runtime=expected)) if ids else []
|
|
1002
|
+
mains = [
|
|
1003
|
+
item
|
|
1004
|
+
for item in descriptions
|
|
1005
|
+
if _object(_object(item, "native container").get("Config"), "native config")
|
|
1006
|
+
.get("Labels", {})
|
|
1007
|
+
.get("com.docker.compose.service")
|
|
1008
|
+
== "main"
|
|
1009
|
+
]
|
|
1010
|
+
if len(mains) != 1:
|
|
1011
|
+
raise ValueError("Native project must have exactly one main container")
|
|
1012
|
+
container = _native_object(mains[0], "native main")
|
|
1013
|
+
config = _native_object(container.get("Config"), "native container config")
|
|
1014
|
+
labels = _native_object(config.get("Labels"), "native container labels")
|
|
1015
|
+
if labels.get("com.docker.compose.project") != project or not re.fullmatch(
|
|
1016
|
+
r"[0-9a-f]{64}", str(container.get("Id"))
|
|
1017
|
+
):
|
|
1018
|
+
raise ValueError("Native container ownership does not match the trial identity")
|
|
1019
|
+
if image_id is not None and container.get("Image") != image_id:
|
|
1020
|
+
raise ValueError("Native container differs from the frozen image")
|
|
1021
|
+
actual = _native_object(container.get("HostConfig"), "native resources")
|
|
1022
|
+
limits = _native_object(expected.get("resources"), "native limits")
|
|
1023
|
+
if (
|
|
1024
|
+
actual.get("NanoCpus") != int(str(limits["cpus"])) * 10**9
|
|
1025
|
+
or actual.get("Memory") != int(str(limits["memory_mb"])) * 1024**2
|
|
1026
|
+
):
|
|
1027
|
+
raise ValueError("Native container resource limits differ from the frozen settings")
|
|
1028
|
+
if (
|
|
1029
|
+
actual.get("Privileged")
|
|
1030
|
+
or actual.get("CapAdd")
|
|
1031
|
+
or actual.get("PidMode") == "host"
|
|
1032
|
+
or actual.get("NetworkMode") == "host"
|
|
1033
|
+
):
|
|
1034
|
+
raise ValueError("Native container has unsupported privileged or host access")
|
|
1035
|
+
allowed = {
|
|
1036
|
+
"/logs/agent": trial_dir / "agent",
|
|
1037
|
+
"/logs/verifier": trial_dir / "verifier",
|
|
1038
|
+
"/logs/artifacts": trial_dir / "artifacts/logs/artifacts",
|
|
1039
|
+
}
|
|
1040
|
+
mounts = container.get("Mounts")
|
|
1041
|
+
if not isinstance(mounts, list):
|
|
1042
|
+
raise ValueError("Native container did not report its mounts")
|
|
1043
|
+
for item in mounts:
|
|
1044
|
+
mount = _native_object(item, "native mount")
|
|
1045
|
+
target = mount.get("Destination")
|
|
1046
|
+
if (
|
|
1047
|
+
not isinstance(target, str)
|
|
1048
|
+
or target not in allowed
|
|
1049
|
+
or mount.get("Type") != "bind"
|
|
1050
|
+
or Path(str(mount.get("Source"))).resolve() != allowed[target].resolve()
|
|
1051
|
+
):
|
|
1052
|
+
raise ValueError("Native container has an unapproved host mount")
|
|
1053
|
+
return container
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def export_native_source(
|
|
1057
|
+
*,
|
|
1058
|
+
destination: Path,
|
|
1059
|
+
project: str,
|
|
1060
|
+
trial_dir: Path,
|
|
1061
|
+
workdir: str,
|
|
1062
|
+
expected: dict[str, object],
|
|
1063
|
+
image_id: str | None = None,
|
|
1064
|
+
resume: bool = False,
|
|
1065
|
+
fresh_source: bool = False,
|
|
1066
|
+
agent_workspace: bool = False,
|
|
1067
|
+
) -> dict[str, object]:
|
|
1068
|
+
"""Freeze actual stopped source before handing it to another phase.
|
|
1069
|
+
|
|
1070
|
+
A symlink is refused, except in an `agent_workspace`: there it is left out and listed in
|
|
1071
|
+
`excluded_runtime_paths`. What an agent leaves beside its work, such as a virtual
|
|
1072
|
+
environment's `bin/python`, decides nothing, because the graded submission is rebuilt from
|
|
1073
|
+
the task's fixture and the committed patch.
|
|
1074
|
+
"""
|
|
1075
|
+
if (
|
|
1076
|
+
re.fullmatch(r"/(?:[A-Za-z0-9_.-]+/?)+", workdir) is None
|
|
1077
|
+
or workdir in {"/root", "/home", "/tmp", "/usr", "/etc", "/var", "/logs"}
|
|
1078
|
+
or ".." in Path(workdir).parts
|
|
1079
|
+
):
|
|
1080
|
+
raise ValueError("Native image must declare a safe explicit repository workdir")
|
|
1081
|
+
container = _native_container(
|
|
1082
|
+
project=project, trial_dir=trial_dir, expected=expected, image_id=image_id
|
|
1083
|
+
)
|
|
1084
|
+
container_id = str(container["Id"])
|
|
1085
|
+
if resume:
|
|
1086
|
+
_docker("stop", "--time", "10", container_id, expected_runtime=expected)
|
|
1087
|
+
container = _native_container(
|
|
1088
|
+
project=project, trial_dir=trial_dir, expected=expected, image_id=image_id
|
|
1089
|
+
)
|
|
1090
|
+
state = stopped_container_state(container.get("State"))
|
|
1091
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
1092
|
+
excluded_runtime_paths: list[str] = []
|
|
1093
|
+
with tempfile.TemporaryDirectory(
|
|
1094
|
+
prefix="pluginbench-native-export-", dir=destination.parent
|
|
1095
|
+
) as temporary:
|
|
1096
|
+
raw = Path(temporary)
|
|
1097
|
+
_docker(
|
|
1098
|
+
"cp", f"{container_id}:{workdir}/.", str(raw), timeout=300, expected_runtime=expected
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
def copy(directory: Path, target: Path) -> None:
|
|
1102
|
+
for path in sorted(directory.iterdir()):
|
|
1103
|
+
if path.name in EXCLUDED_NAMES:
|
|
1104
|
+
continue
|
|
1105
|
+
if path.is_symlink():
|
|
1106
|
+
if not agent_workspace:
|
|
1107
|
+
raise ValueError(
|
|
1108
|
+
f"Native source contains a symlink: {path.relative_to(raw)}"
|
|
1109
|
+
)
|
|
1110
|
+
excluded_runtime_paths.append(path.relative_to(raw).as_posix())
|
|
1111
|
+
continue
|
|
1112
|
+
if fresh_source and path.name == ".pytest_cache" and path.is_dir():
|
|
1113
|
+
excluded_runtime_paths.append(path.relative_to(raw).as_posix())
|
|
1114
|
+
continue
|
|
1115
|
+
output = target / path.name
|
|
1116
|
+
if path.is_dir():
|
|
1117
|
+
output.mkdir()
|
|
1118
|
+
copy(path, output)
|
|
1119
|
+
shutil.copystat(path, output, follow_symlinks=False)
|
|
1120
|
+
elif path.is_file():
|
|
1121
|
+
shutil.copy2(path, output)
|
|
1122
|
+
else:
|
|
1123
|
+
raise ValueError("Native source contains a nonregular file")
|
|
1124
|
+
|
|
1125
|
+
copy(raw, destination)
|
|
1126
|
+
snapshot = inspect_snapshot(destination)
|
|
1127
|
+
receipt = {
|
|
1128
|
+
"status": "complete",
|
|
1129
|
+
"stopped": True,
|
|
1130
|
+
"container_id": container_id,
|
|
1131
|
+
"project": project,
|
|
1132
|
+
"state": state,
|
|
1133
|
+
"snapshot_digest": snapshot["digest"],
|
|
1134
|
+
"source_metadata_digest": native_task_assets(destination),
|
|
1135
|
+
"capture_policy": "fresh-source-v1" if fresh_source else "exact-replay-v1",
|
|
1136
|
+
"excluded_runtime_paths": excluded_runtime_paths,
|
|
1137
|
+
"image_id": container["Image"],
|
|
1138
|
+
"workdir": workdir,
|
|
1139
|
+
"resources": {
|
|
1140
|
+
key: value
|
|
1141
|
+
for key, value in _native_object(
|
|
1142
|
+
expected.get("resources", {}), "native export resources"
|
|
1143
|
+
).items()
|
|
1144
|
+
if key in {"cpus", "memory_mb"}
|
|
1145
|
+
},
|
|
1146
|
+
}
|
|
1147
|
+
if resume:
|
|
1148
|
+
_docker("start", container_id, expected_runtime=expected)
|
|
1149
|
+
return receipt
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def install_native_hooks(trial: object, boundary: dict[str, object]) -> None:
|
|
1153
|
+
"""Attach public Harbor hooks without changing native verifier semantics."""
|
|
1154
|
+
events = importlib.import_module("harbor.trial.hooks").TrialEvent
|
|
1155
|
+
area = Path(str(boundary["area"]))
|
|
1156
|
+
expected = _native_object(boundary["runtime"], "native runtime")
|
|
1157
|
+
image = _native_object(boundary["image"], "native image")
|
|
1158
|
+
# Harbor's runtime objects remain at this untyped SDK boundary.
|
|
1159
|
+
trial_name = getattr(getattr(trial, "config"), "trial_name")
|
|
1160
|
+
add_hook = getattr(trial, "add_hook")
|
|
1161
|
+
project = trial_name + "__env"
|
|
1162
|
+
trial_dir = area / "artifacts/harbor" / trial_name
|
|
1163
|
+
|
|
1164
|
+
async def freeze_before(event: object) -> None:
|
|
1165
|
+
receipt = export_native_source(
|
|
1166
|
+
destination=area / "before",
|
|
1167
|
+
project=project,
|
|
1168
|
+
trial_dir=trial_dir,
|
|
1169
|
+
workdir=str(image["workdir"]),
|
|
1170
|
+
expected=expected,
|
|
1171
|
+
image_id=str(image["image_id"]),
|
|
1172
|
+
resume=True,
|
|
1173
|
+
fresh_source=True,
|
|
1174
|
+
)
|
|
1175
|
+
codex.write_json(area / "artifacts/before-source.json", receipt)
|
|
1176
|
+
if receipt["snapshot_digest"] != boundary["source_digest"]:
|
|
1177
|
+
raise ValueError(
|
|
1178
|
+
"Native starting source differs from the prepared fixture; model call refused"
|
|
1179
|
+
)
|
|
1180
|
+
metadata = boundary.get("source_metadata_digest")
|
|
1181
|
+
if metadata is not None and receipt.get("source_metadata_digest") != metadata:
|
|
1182
|
+
raise ValueError("Native starting source metadata changed; model call refused")
|
|
1183
|
+
|
|
1184
|
+
async def freeze_after(event: object) -> None:
|
|
1185
|
+
receipt = export_native_source(
|
|
1186
|
+
destination=area / "workspace",
|
|
1187
|
+
project=project,
|
|
1188
|
+
trial_dir=trial_dir,
|
|
1189
|
+
workdir=str(image["workdir"]),
|
|
1190
|
+
expected=expected,
|
|
1191
|
+
image_id=str(image["image_id"]),
|
|
1192
|
+
fresh_source=True,
|
|
1193
|
+
agent_workspace=True,
|
|
1194
|
+
)
|
|
1195
|
+
codex.write_json(area / "artifacts/submission-source.json", receipt)
|
|
1196
|
+
|
|
1197
|
+
agent = getattr(trial, "agent")
|
|
1198
|
+
if boundary.get("install_only"):
|
|
1199
|
+
original_install = getattr(agent, "install")
|
|
1200
|
+
|
|
1201
|
+
async def qualified_install(environment: object) -> None:
|
|
1202
|
+
await original_install(environment)
|
|
1203
|
+
result = await getattr(agent, "exec_as_agent")(
|
|
1204
|
+
environment,
|
|
1205
|
+
command=_native_cli_qualification(
|
|
1206
|
+
str(boundary["harness"]), str(boundary["version"])
|
|
1207
|
+
),
|
|
1208
|
+
)
|
|
1209
|
+
if getattr(result, "return_code") != 0:
|
|
1210
|
+
raise ValueError("Native CLI qualification failed during setup")
|
|
1211
|
+
command = (
|
|
1212
|
+
"set -eu; rm -rf /tmp/codex-home /tmp/codex-secrets /root/.codex /root/.claude /root/.agents /root/.config /root/.claude.json /AGENTS.md /AGENTS.override.md /CLAUDE.md; find "
|
|
1213
|
+
+ shlex.quote(str(image["workdir"]))
|
|
1214
|
+
)
|
|
1215
|
+
command += (
|
|
1216
|
+
" -mindepth 1 \\( -name .git -o -name node_modules \\) -prune -o \\( "
|
|
1217
|
+
+ " -o ".join("-name " + shlex.quote(name) for name in sorted(codex.GUIDANCE_NAMES))
|
|
1218
|
+
+ " \\) -prune -exec rm -rf -- {} +"
|
|
1219
|
+
)
|
|
1220
|
+
result = await getattr(agent, "exec_as_root")(environment, command=command)
|
|
1221
|
+
if getattr(result, "return_code") != 0:
|
|
1222
|
+
raise ValueError("Native source sanitation failed during setup")
|
|
1223
|
+
|
|
1224
|
+
setattr(agent, "install", qualified_install)
|
|
1225
|
+
else:
|
|
1226
|
+
add_hook(events.AGENT_START, freeze_before)
|
|
1227
|
+
add_hook(events.VERIFICATION_START, freeze_after)
|
|
1228
|
+
|
|
1229
|
+
async def verify_installed(environment: object) -> None:
|
|
1230
|
+
execute = getattr(agent, "exec_as_agent")
|
|
1231
|
+
result = await execute(
|
|
1232
|
+
environment,
|
|
1233
|
+
_native_cli_qualification(str(boundary["harness"]), str(boundary["version"])),
|
|
1234
|
+
)
|
|
1235
|
+
if getattr(result, "return_code") != 0:
|
|
1236
|
+
raise ValueError("Frozen native CLI changed; model call refused")
|
|
1237
|
+
|
|
1238
|
+
setattr(agent, "install", verify_installed)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _native_cli_qualification(harness: str, version: str) -> str:
|
|
1242
|
+
prelude = _CODEX_EXEC_PREFIX.partition("codex exec")[0] if harness == "codex" else ""
|
|
1243
|
+
return prelude + "set -eu; " + _cli_guard(harness, version)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _native_config(
|
|
1247
|
+
*,
|
|
1248
|
+
task_dir: Path,
|
|
1249
|
+
area: Path,
|
|
1250
|
+
owner: str,
|
|
1251
|
+
harness: str,
|
|
1252
|
+
version: str,
|
|
1253
|
+
limits: dict[str, object],
|
|
1254
|
+
timeout: int,
|
|
1255
|
+
model: str | None = None,
|
|
1256
|
+
effort: str | None = None,
|
|
1257
|
+
prompt: str = "",
|
|
1258
|
+
treatment: Treatment | None = None,
|
|
1259
|
+
capabilities: NativeCapabilities | None = None,
|
|
1260
|
+
agent_env: dict[str, str] | None = None,
|
|
1261
|
+
) -> dict[str, object]:
|
|
1262
|
+
kwargs: dict[str, object] = {
|
|
1263
|
+
"version": version,
|
|
1264
|
+
"config": {"features": {"code_mode": True, "code_mode_only": True, "multi_agent": False}}
|
|
1265
|
+
if harness == "codex"
|
|
1266
|
+
else {},
|
|
1267
|
+
}
|
|
1268
|
+
if effort is not None:
|
|
1269
|
+
kwargs["reasoning_effort"] = effort
|
|
1270
|
+
if treatment is not None:
|
|
1271
|
+
native_treatments.admit(treatment, harness)
|
|
1272
|
+
if prompt:
|
|
1273
|
+
raise ValueError("native treatments cannot be appended to the task prompt")
|
|
1274
|
+
if capabilities is None or model is None or effort is None:
|
|
1275
|
+
raise ValueError("native treatment execution requires a fixed capability/model policy")
|
|
1276
|
+
kwargs["config"] = capabilities.codex_config(model=model, effort=effort)
|
|
1277
|
+
kwargs["dangerously_bypass_hook_trust"] = True
|
|
1278
|
+
return {
|
|
1279
|
+
"task": {"path": str(task_dir.resolve())},
|
|
1280
|
+
"trial_name": "pluginbench-" + owner,
|
|
1281
|
+
"trials_dir": str((area / "artifacts/harbor").resolve()),
|
|
1282
|
+
"agent": {
|
|
1283
|
+
"name": "codex" if harness == "codex" else "claude-code",
|
|
1284
|
+
"model_name": model,
|
|
1285
|
+
"override_timeout_sec": timeout,
|
|
1286
|
+
"override_setup_timeout_sec": 600,
|
|
1287
|
+
"kwargs": kwargs,
|
|
1288
|
+
"skills": [str(path) for path in treatment.skills] if treatment is not None else [],
|
|
1289
|
+
"mcp_servers": [],
|
|
1290
|
+
"extra_allowed_hosts": NATIVE_PROVIDER_HOSTS[harness],
|
|
1291
|
+
# Set only when PluginBench supplies a git identity, so other launches are unchanged.
|
|
1292
|
+
**({"env": agent_env} if agent_env else {}),
|
|
1293
|
+
},
|
|
1294
|
+
"environment": {
|
|
1295
|
+
"type": "docker",
|
|
1296
|
+
"delete": True,
|
|
1297
|
+
"override_cpus": limits["cpus"],
|
|
1298
|
+
"override_memory_mb": limits["memory_mb"],
|
|
1299
|
+
"cpu_enforcement_policy": "limit",
|
|
1300
|
+
"memory_enforcement_policy": "limit",
|
|
1301
|
+
"kwargs": {"keep_containers": True},
|
|
1302
|
+
},
|
|
1303
|
+
"verifier": {"disable": False},
|
|
1304
|
+
"extra_instructions": [prompt] if prompt else [],
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def _native_worker_budget(*, task_dir: Path, agent_seconds: int = 0, prepare: bool = False) -> int:
|
|
1309
|
+
task = importlib.import_module("harbor.models.task.task").Task(task_dir)
|
|
1310
|
+
build = int(task.config.environment.build_timeout_sec)
|
|
1311
|
+
if prepare:
|
|
1312
|
+
return build + 600 + 360
|
|
1313
|
+
collect = sum(int(item.timeout_sec or 300) for item in task.config.verifier.collect)
|
|
1314
|
+
return agent_seconds + 600 + 2 * build + int(task.config.verifier.timeout_sec) + collect + 720
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _native_process(
|
|
1318
|
+
*,
|
|
1319
|
+
area: Path,
|
|
1320
|
+
config: dict[str, object],
|
|
1321
|
+
expected: dict[str, object],
|
|
1322
|
+
harness: str,
|
|
1323
|
+
timeout: int,
|
|
1324
|
+
install_only: bool,
|
|
1325
|
+
auth_file: Path | None = None,
|
|
1326
|
+
) -> dict[str, object]:
|
|
1327
|
+
private = area / "private"
|
|
1328
|
+
artifacts = area / "artifacts"
|
|
1329
|
+
secrets: tuple[str, ...] = ()
|
|
1330
|
+
if install_only:
|
|
1331
|
+
private.mkdir(mode=0o700)
|
|
1332
|
+
home = private / "home"
|
|
1333
|
+
home.mkdir()
|
|
1334
|
+
env = {
|
|
1335
|
+
key: value
|
|
1336
|
+
for key, value in os.environ.items()
|
|
1337
|
+
if key
|
|
1338
|
+
in {
|
|
1339
|
+
"PATH",
|
|
1340
|
+
"LANG",
|
|
1341
|
+
"SSL_CERT_FILE",
|
|
1342
|
+
"SSL_CERT_DIR",
|
|
1343
|
+
"HTTP_PROXY",
|
|
1344
|
+
"HTTPS_PROXY",
|
|
1345
|
+
"NO_PROXY",
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
env.update(HOME=str(home), XDG_CONFIG_HOME=str(home / ".config"))
|
|
1349
|
+
else:
|
|
1350
|
+
env, secrets = codex.runtime_environment(private, harness, auth_file=auth_file)
|
|
1351
|
+
if harness == "codex":
|
|
1352
|
+
env["CODEX_AUTH_JSON_PATH"] = str(private / "codex/auth.json")
|
|
1353
|
+
engine = _native_object(expected["docker_engine"], "native engine")
|
|
1354
|
+
env.update(
|
|
1355
|
+
DOCKER_HOST=str(engine["endpoint"]),
|
|
1356
|
+
PYTHON_DOTENV_DISABLED="1",
|
|
1357
|
+
PYTHONPATH=str(Path(__file__).resolve().parents[1]),
|
|
1358
|
+
PYTHONDONTWRITEBYTECODE="1",
|
|
1359
|
+
)
|
|
1360
|
+
try:
|
|
1361
|
+
codex.write_json(
|
|
1362
|
+
artifacts / "docker-client.json",
|
|
1363
|
+
_private_docker_config(private=private, env=env, expected_runtime=expected),
|
|
1364
|
+
)
|
|
1365
|
+
codex.write_json(private / "trial.json", config)
|
|
1366
|
+
result = run_command(
|
|
1367
|
+
[
|
|
1368
|
+
sys.executable,
|
|
1369
|
+
"-m",
|
|
1370
|
+
"pluginbench.harbor_backend",
|
|
1371
|
+
str(private / "trial.json"),
|
|
1372
|
+
str(artifacts / "worker.json"),
|
|
1373
|
+
],
|
|
1374
|
+
cwd=private,
|
|
1375
|
+
env=env,
|
|
1376
|
+
timeout=timeout,
|
|
1377
|
+
)
|
|
1378
|
+
(artifacts / "harbor-worker.stdout").write_text(result.stdout)
|
|
1379
|
+
(artifacts / "harbor-worker.stderr").write_text(result.stderr)
|
|
1380
|
+
if result.returncode:
|
|
1381
|
+
raise ValueError(f"Native Harbor worker failed (exit {result.returncode})")
|
|
1382
|
+
return _native_object(json.loads((artifacts / "worker.json").read_text()), "native worker")
|
|
1383
|
+
finally:
|
|
1384
|
+
shutil.rmtree(private)
|
|
1385
|
+
codex.scrub_artifacts(artifacts, secrets)
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _qualify_native_source(
|
|
1389
|
+
*,
|
|
1390
|
+
task_dir: Path,
|
|
1391
|
+
fixture: Path,
|
|
1392
|
+
coding_image: dict[str, str],
|
|
1393
|
+
grader: dict[str, str],
|
|
1394
|
+
expected: dict[str, object],
|
|
1395
|
+
destination: Path,
|
|
1396
|
+
) -> dict[str, object]:
|
|
1397
|
+
"""No-model proof of the reviewed grader's base inventory and initial source parity."""
|
|
1398
|
+
proof: dict[str, object] = {"status": "unknown", "source_policy": submissions.SOURCE_POLICY}
|
|
1399
|
+
try:
|
|
1400
|
+
contract = submissions.reviewed_contract(task_dir)
|
|
1401
|
+
except (OSError, ValueError) as error:
|
|
1402
|
+
proof["reason"] = str(error)
|
|
1403
|
+
return proof
|
|
1404
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
1405
|
+
owner = uuid4().hex
|
|
1406
|
+
project = "pluginbench-source-proof-" + owner
|
|
1407
|
+
limits = _native_object(expected["resources"], "source proof resources")
|
|
1408
|
+
container_id = ""
|
|
1409
|
+
try:
|
|
1410
|
+
base = str(contract["base_commit"])
|
|
1411
|
+
workdir = grader["workdir"]
|
|
1412
|
+
if workdir != "/app" or coding_image["workdir"] != "/app":
|
|
1413
|
+
raise ValueError("reviewed native grader requires the /app workdir")
|
|
1414
|
+
frozen_grader = _inspect_image(grader["image_id"], pull=False, expected_runtime=expected)
|
|
1415
|
+
if frozen_grader.get("Id") != grader["image_id"]:
|
|
1416
|
+
raise ValueError("native grader image identity changed during source qualification")
|
|
1417
|
+
startup = submissions.grader_startup(
|
|
1418
|
+
config=frozen_grader.get("Config"), image_id=grader["image_id"]
|
|
1419
|
+
)
|
|
1420
|
+
command = _base_inventory_command(base)
|
|
1421
|
+
container_id = _docker(
|
|
1422
|
+
"create",
|
|
1423
|
+
"--label",
|
|
1424
|
+
"com.docker.compose.project=" + project,
|
|
1425
|
+
"--label",
|
|
1426
|
+
"com.docker.compose.service=main",
|
|
1427
|
+
"--label",
|
|
1428
|
+
_OWNER_LABEL + "=" + owner,
|
|
1429
|
+
"--cpus",
|
|
1430
|
+
str(limits["cpus"]),
|
|
1431
|
+
"--memory",
|
|
1432
|
+
str(limits["memory_mb"]) + "m",
|
|
1433
|
+
"--network",
|
|
1434
|
+
"none",
|
|
1435
|
+
"--workdir",
|
|
1436
|
+
workdir,
|
|
1437
|
+
"--entrypoint",
|
|
1438
|
+
"bash",
|
|
1439
|
+
grader["image_id"],
|
|
1440
|
+
"-c",
|
|
1441
|
+
command,
|
|
1442
|
+
expected_runtime=expected,
|
|
1443
|
+
).strip()
|
|
1444
|
+
# Not `start --attach`: that exits with the container's status, and a failed inventory
|
|
1445
|
+
# would then surface as "docker start failed" without the reason in the logs.
|
|
1446
|
+
_docker("start", container_id, expected_runtime=expected)
|
|
1447
|
+
exit_code = _docker("wait", container_id, timeout=60, expected_runtime=expected).strip()
|
|
1448
|
+
output = _docker("logs", container_id, expected_runtime=expected)
|
|
1449
|
+
if exit_code != "0":
|
|
1450
|
+
raise ValueError(
|
|
1451
|
+
f"native grader base inventory command failed (exit {exit_code}): "
|
|
1452
|
+
+ output.strip()
|
|
1453
|
+
)
|
|
1454
|
+
(destination / "base-tree.raw").write_text(output)
|
|
1455
|
+
head, separator, inventory = output.partition("\n")
|
|
1456
|
+
if not separator or head != base:
|
|
1457
|
+
raise ValueError("native grader HEAD differs from reviewed verifier base")
|
|
1458
|
+
tree = submissions.base_tree_manifest(inventory)
|
|
1459
|
+
exported = export_native_source(
|
|
1460
|
+
destination=destination / "fixture",
|
|
1461
|
+
project=project,
|
|
1462
|
+
trial_dir=destination,
|
|
1463
|
+
workdir=workdir,
|
|
1464
|
+
expected=expected,
|
|
1465
|
+
image_id=grader["image_id"],
|
|
1466
|
+
fresh_source=True,
|
|
1467
|
+
)
|
|
1468
|
+
if exported["snapshot_digest"] != inspect_snapshot(fixture)["digest"] or exported[
|
|
1469
|
+
"source_metadata_digest"
|
|
1470
|
+
] != native_task_assets(fixture):
|
|
1471
|
+
raise ValueError("native grader initial source differs from frozen coding fixture")
|
|
1472
|
+
payload_dir = destination / "tests"
|
|
1473
|
+
payload_dir.mkdir()
|
|
1474
|
+
_docker(
|
|
1475
|
+
"cp",
|
|
1476
|
+
container_id + ":/tests/.",
|
|
1477
|
+
str(payload_dir),
|
|
1478
|
+
expected_runtime=expected,
|
|
1479
|
+
timeout=60,
|
|
1480
|
+
)
|
|
1481
|
+
_native_asset_inventory(payload_dir)
|
|
1482
|
+
payload = {
|
|
1483
|
+
path.name: hashlib.sha256(path.read_bytes()).hexdigest()
|
|
1484
|
+
for path in payload_dir.iterdir()
|
|
1485
|
+
if path.is_file()
|
|
1486
|
+
}
|
|
1487
|
+
if (
|
|
1488
|
+
payload != contract["verifier_payload"]
|
|
1489
|
+
or any(not path.is_file() for path in payload_dir.iterdir())
|
|
1490
|
+
or not (payload_dir / "test.sh").stat().st_mode & 0o111
|
|
1491
|
+
):
|
|
1492
|
+
raise ValueError("native grader image verifier differs from reviewed frozen tests")
|
|
1493
|
+
proof.update(
|
|
1494
|
+
status="complete",
|
|
1495
|
+
contract=contract,
|
|
1496
|
+
grader_head=head,
|
|
1497
|
+
base_tree=tree,
|
|
1498
|
+
base_tree_sha256=hashlib.sha256(json.dumps(tree, sort_keys=True).encode()).hexdigest(),
|
|
1499
|
+
fixture_digest=exported["snapshot_digest"],
|
|
1500
|
+
fixture_metadata_digest=exported["source_metadata_digest"],
|
|
1501
|
+
coding_image_id=coding_image["image_id"],
|
|
1502
|
+
grader_image_id=grader["image_id"],
|
|
1503
|
+
grader_tests_sha256=hashlib.sha256(
|
|
1504
|
+
json.dumps(payload, sort_keys=True).encode()
|
|
1505
|
+
).hexdigest(),
|
|
1506
|
+
grader_startup=startup,
|
|
1507
|
+
export=exported,
|
|
1508
|
+
)
|
|
1509
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
1510
|
+
proof.update(status="unknown", reason=str(error))
|
|
1511
|
+
finally:
|
|
1512
|
+
try:
|
|
1513
|
+
proof["cleanup"] = cleanup(project=project, owner=owner, expected_runtime=expected)
|
|
1514
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
1515
|
+
proof["cleanup"] = {"status": "incomplete", "error": str(error)}
|
|
1516
|
+
if _native_object(proof["cleanup"], "source proof cleanup").get("status") != "complete":
|
|
1517
|
+
proof.update(status="unknown", reason="native source qualification cleanup incomplete")
|
|
1518
|
+
codex.write_json(destination / "qualification.json", proof)
|
|
1519
|
+
return proof
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def _prepare_native_task(
|
|
1523
|
+
*,
|
|
1524
|
+
task_dir: Path,
|
|
1525
|
+
destination: Path,
|
|
1526
|
+
harness: str,
|
|
1527
|
+
version: str,
|
|
1528
|
+
limits: dict[str, int],
|
|
1529
|
+
resolved: _NativePreparation | None = None,
|
|
1530
|
+
) -> dict[str, object]:
|
|
1531
|
+
"""Freeze native task, installed CLI, starting source and separate grader once."""
|
|
1532
|
+
admission = resolved.admission if resolved else validate_native_task(task_dir)
|
|
1533
|
+
version = _normalize_version(version)
|
|
1534
|
+
_cli_guard(harness, version)
|
|
1535
|
+
expected = (
|
|
1536
|
+
resolved.runtime
|
|
1537
|
+
if resolved
|
|
1538
|
+
else runtime_identity(cpus=limits["cpus"], memory_mb=limits["memory_mb"])
|
|
1539
|
+
)
|
|
1540
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
1541
|
+
artifacts = destination / "artifacts"
|
|
1542
|
+
artifacts.mkdir()
|
|
1543
|
+
frozen = destination / "task"
|
|
1544
|
+
shutil.copytree(task_dir, frozen)
|
|
1545
|
+
if native_task_assets(frozen) != admission["task_assets_sha256"]:
|
|
1546
|
+
raise ValueError("Native task changed while freezing preparation inputs")
|
|
1547
|
+
task_type = importlib.import_module("harbor.models.task.task").Task
|
|
1548
|
+
task = task_type(frozen)
|
|
1549
|
+
modes = importlib.import_module("harbor.models.task.verifier_mode")
|
|
1550
|
+
definition = modes.resolve_verifier_environment_definition(task.config, task.paths)
|
|
1551
|
+
base = (
|
|
1552
|
+
resolved.base
|
|
1553
|
+
if resolved
|
|
1554
|
+
else _native_image(
|
|
1555
|
+
directory=task.paths.environment_dir,
|
|
1556
|
+
image=task.config.environment.docker_image,
|
|
1557
|
+
expected=expected,
|
|
1558
|
+
)
|
|
1559
|
+
)
|
|
1560
|
+
grader = (
|
|
1561
|
+
resolved.grader
|
|
1562
|
+
if resolved
|
|
1563
|
+
else _native_image(
|
|
1564
|
+
directory=definition.directory, image=definition.config.docker_image, expected=expected
|
|
1565
|
+
)
|
|
1566
|
+
)
|
|
1567
|
+
test_delivery = "native-image"
|
|
1568
|
+
if not definition.bundled_tests:
|
|
1569
|
+
grader = _bundle_native_tests(tests=task.paths.tests_dir, grader=grader, expected=expected)
|
|
1570
|
+
test_delivery = "native-upload-frozen-into-image"
|
|
1571
|
+
task.config.environment.docker_image = base["image"]
|
|
1572
|
+
verifier_config = definition.config.model_copy(update={"docker_image": grader["image"]})
|
|
1573
|
+
task.config.verifier.environment = verifier_config
|
|
1574
|
+
(frozen / "task.toml").write_text(task.config.model_dump_toml())
|
|
1575
|
+
owner = uuid4().hex
|
|
1576
|
+
project = "pluginbench-" + owner + "__env"
|
|
1577
|
+
config = _native_config(
|
|
1578
|
+
task_dir=frozen,
|
|
1579
|
+
area=destination,
|
|
1580
|
+
owner=owner,
|
|
1581
|
+
harness=harness,
|
|
1582
|
+
version=version,
|
|
1583
|
+
limits=dict(limits),
|
|
1584
|
+
timeout=limits.get("setup_timeout", 600),
|
|
1585
|
+
)
|
|
1586
|
+
config["install_only"] = True
|
|
1587
|
+
config["pluginbench_boundary"] = {
|
|
1588
|
+
"area": str(destination.resolve()),
|
|
1589
|
+
"runtime": expected,
|
|
1590
|
+
"image": base,
|
|
1591
|
+
"harness": harness,
|
|
1592
|
+
"version": version,
|
|
1593
|
+
"install_only": True,
|
|
1594
|
+
}
|
|
1595
|
+
failure: BaseException | None = None
|
|
1596
|
+
receipt: dict[str, object] = {"status": "incomplete", "cleanup": {"status": "incomplete"}}
|
|
1597
|
+
try:
|
|
1598
|
+
worker = _native_process(
|
|
1599
|
+
area=destination,
|
|
1600
|
+
config=config,
|
|
1601
|
+
expected=expected,
|
|
1602
|
+
harness=harness,
|
|
1603
|
+
timeout=_native_worker_budget(task_dir=frozen, prepare=True),
|
|
1604
|
+
install_only=True,
|
|
1605
|
+
)
|
|
1606
|
+
result = _native_object(worker["result"], "native installation result")
|
|
1607
|
+
if worker.get("session_id") != project or result.get("exception_info") is not None:
|
|
1608
|
+
raise ValueError(
|
|
1609
|
+
f"Native agent installation did not complete: {result.get('exception_info')}"
|
|
1610
|
+
)
|
|
1611
|
+
container = _native_container(
|
|
1612
|
+
project=project,
|
|
1613
|
+
trial_dir=artifacts / "harbor" / ("pluginbench-" + owner),
|
|
1614
|
+
expected=expected,
|
|
1615
|
+
image_id=base["image_id"],
|
|
1616
|
+
)
|
|
1617
|
+
container_id = str(container["Id"])
|
|
1618
|
+
fixture = destination / "fixture"
|
|
1619
|
+
export = export_native_source(
|
|
1620
|
+
destination=fixture,
|
|
1621
|
+
project=project,
|
|
1622
|
+
trial_dir=artifacts / "harbor" / ("pluginbench-" + owner),
|
|
1623
|
+
workdir=base["workdir"],
|
|
1624
|
+
expected=expected,
|
|
1625
|
+
image_id=base["image_id"],
|
|
1626
|
+
fresh_source=True,
|
|
1627
|
+
)
|
|
1628
|
+
installed_id = _docker("commit", container_id, expected_runtime=expected).strip()
|
|
1629
|
+
installed = _native_image(
|
|
1630
|
+
directory=frozen / "environment", image=installed_id, expected=expected
|
|
1631
|
+
)
|
|
1632
|
+
task.config.environment.docker_image = installed["image"]
|
|
1633
|
+
(frozen / "task.toml").write_text(task.config.model_dump_toml())
|
|
1634
|
+
qualification = _qualify_native_source(
|
|
1635
|
+
task_dir=frozen,
|
|
1636
|
+
fixture=fixture,
|
|
1637
|
+
coding_image=installed,
|
|
1638
|
+
grader=grader,
|
|
1639
|
+
expected=expected,
|
|
1640
|
+
destination=artifacts / "source-qualification",
|
|
1641
|
+
)
|
|
1642
|
+
receipt["source_qualification"] = qualification
|
|
1643
|
+
qualification_cleanup = qualification.get("cleanup")
|
|
1644
|
+
if (
|
|
1645
|
+
isinstance(qualification_cleanup, dict)
|
|
1646
|
+
and qualification_cleanup.get("status") != "complete"
|
|
1647
|
+
):
|
|
1648
|
+
raise ValueError("Native source qualification cleanup incomplete")
|
|
1649
|
+
receipt.update(
|
|
1650
|
+
status="complete",
|
|
1651
|
+
task_dir=str(frozen.resolve()),
|
|
1652
|
+
fixture_dir=str(fixture.resolve()),
|
|
1653
|
+
image_identity=installed,
|
|
1654
|
+
toolchain_identity={
|
|
1655
|
+
"image_id": installed["image_id"],
|
|
1656
|
+
"harness": harness,
|
|
1657
|
+
"harness_version": version,
|
|
1658
|
+
"platform": installed["platform"],
|
|
1659
|
+
"install_policy": "harbor-native-install-only-v1",
|
|
1660
|
+
"base_image_identity": base,
|
|
1661
|
+
},
|
|
1662
|
+
grader_image_identity=grader,
|
|
1663
|
+
verifier_test_delivery=test_delivery,
|
|
1664
|
+
trusted_tests_sha256=native_task_assets(task.paths.tests_dir),
|
|
1665
|
+
workdir=base["workdir"],
|
|
1666
|
+
source_digest=export["snapshot_digest"],
|
|
1667
|
+
task_assets_sha256=native_task_assets(frozen),
|
|
1668
|
+
original_task_assets_sha256=admission["task_assets_sha256"],
|
|
1669
|
+
resources=limits,
|
|
1670
|
+
isolation=expected,
|
|
1671
|
+
transport_policy={
|
|
1672
|
+
"name": NATIVE_TRANSPORT_POLICY,
|
|
1673
|
+
"provider_hosts": NATIVE_PROVIDER_HOSTS[harness],
|
|
1674
|
+
},
|
|
1675
|
+
preparation_policy=_PREPARATION_POLICY,
|
|
1676
|
+
source_policy=submissions.SOURCE_POLICY,
|
|
1677
|
+
source_qualification=qualification,
|
|
1678
|
+
)
|
|
1679
|
+
except BaseException as error:
|
|
1680
|
+
failure = error
|
|
1681
|
+
receipt["error"] = str(error)
|
|
1682
|
+
finally:
|
|
1683
|
+
try:
|
|
1684
|
+
receipt["cleanup"] = cleanup(project=project, owner=owner, expected_runtime=expected)
|
|
1685
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
1686
|
+
receipt["cleanup"] = {"status": "incomplete", "error": str(error)}
|
|
1687
|
+
if failure is None:
|
|
1688
|
+
failure = error
|
|
1689
|
+
else:
|
|
1690
|
+
failure.add_note("Native preparation cleanup failed: " + str(error))
|
|
1691
|
+
codex.write_json(artifacts / "native-preparation.json", receipt)
|
|
1692
|
+
if failure is not None:
|
|
1693
|
+
raise failure
|
|
1694
|
+
return receipt
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def prepare_native_task(
|
|
1698
|
+
*,
|
|
1699
|
+
task_dir: Path,
|
|
1700
|
+
destination: Path,
|
|
1701
|
+
harness: str,
|
|
1702
|
+
version: str,
|
|
1703
|
+
limits: dict[str, int],
|
|
1704
|
+
cache: Path | None = None,
|
|
1705
|
+
) -> dict[str, object]:
|
|
1706
|
+
"""Reuse verified prepared source/images across evaluations with identical inputs."""
|
|
1707
|
+
version = _normalize_version(version)
|
|
1708
|
+
_cli_guard(harness, version)
|
|
1709
|
+
if cache is None:
|
|
1710
|
+
return _prepare_native_task(
|
|
1711
|
+
task_dir=task_dir,
|
|
1712
|
+
destination=destination,
|
|
1713
|
+
harness=harness,
|
|
1714
|
+
version=version,
|
|
1715
|
+
limits=limits,
|
|
1716
|
+
)
|
|
1717
|
+
admission = validate_native_task(task_dir)
|
|
1718
|
+
expected = runtime_identity(cpus=limits["cpus"], memory_mb=limits["memory_mb"])
|
|
1719
|
+
task = importlib.import_module("harbor.models.task.task").Task(task_dir)
|
|
1720
|
+
modes = importlib.import_module("harbor.models.task.verifier_mode")
|
|
1721
|
+
definition = modes.resolve_verifier_environment_definition(task.config, task.paths)
|
|
1722
|
+
base = _native_image(
|
|
1723
|
+
directory=task.paths.environment_dir,
|
|
1724
|
+
image=task.config.environment.docker_image,
|
|
1725
|
+
expected=expected,
|
|
1726
|
+
)
|
|
1727
|
+
grader = _native_image(
|
|
1728
|
+
directory=definition.directory, image=definition.config.docker_image, expected=expected
|
|
1729
|
+
)
|
|
1730
|
+
agent_module = importlib.import_module(
|
|
1731
|
+
"harbor.agents.installed.codex"
|
|
1732
|
+
if harness == "codex"
|
|
1733
|
+
else "harbor.agents.installed.claude_code"
|
|
1734
|
+
)
|
|
1735
|
+
agent_type = getattr(agent_module, "Codex" if harness == "codex" else "ClaudeCode")
|
|
1736
|
+
inputs: dict[str, object] = {
|
|
1737
|
+
"task_assets_sha256": admission["task_assets_sha256"],
|
|
1738
|
+
"base_image": base,
|
|
1739
|
+
"grader_base_image": grader,
|
|
1740
|
+
"harness": harness,
|
|
1741
|
+
"version": _normalize_version(version),
|
|
1742
|
+
"resources": limits,
|
|
1743
|
+
"runtime": expected,
|
|
1744
|
+
"install_sha256": hashlib.sha256(
|
|
1745
|
+
inspect.getsource(agent_type.install).encode()
|
|
1746
|
+
).hexdigest(),
|
|
1747
|
+
"policy": _PREPARATION_POLICY,
|
|
1748
|
+
"source_policy": submissions.SOURCE_POLICY,
|
|
1749
|
+
"transport_policy": {
|
|
1750
|
+
"name": NATIVE_TRANSPORT_POLICY,
|
|
1751
|
+
"provider_hosts": NATIVE_PROVIDER_HOSTS[harness],
|
|
1752
|
+
},
|
|
1753
|
+
}
|
|
1754
|
+
# Keyed by the runtime contract, so recorded and fit runtime fields never miss the cache.
|
|
1755
|
+
contract = _preparation_contract(inputs)
|
|
1756
|
+
key = hashlib.sha256(json.dumps(contract, sort_keys=True).encode()).hexdigest()
|
|
1757
|
+
if cache.is_symlink():
|
|
1758
|
+
raise ValueError("Native preparation cache must not follow symlinks")
|
|
1759
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
1760
|
+
lock_path = cache / (key + ".lock")
|
|
1761
|
+
cached = cache / key
|
|
1762
|
+
if lock_path.is_symlink() or cached.is_symlink():
|
|
1763
|
+
raise ValueError("Native preparation cache entries must not follow symlinks")
|
|
1764
|
+
with lock_path.open("a") as lock, ExitStack() as held:
|
|
1765
|
+
try:
|
|
1766
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1767
|
+
except BlockingIOError as error:
|
|
1768
|
+
raise ValueError(
|
|
1769
|
+
"Native preparation inputs are in use by another evaluation"
|
|
1770
|
+
) from error
|
|
1771
|
+
saved = None if cached.exists() else _saved_preparation(cache, contract, expected, held)
|
|
1772
|
+
if saved is None:
|
|
1773
|
+
if not cached.exists():
|
|
1774
|
+
with tempfile.TemporaryDirectory(prefix=".native-prepare-", dir=cache) as temporary:
|
|
1775
|
+
prepared = Path(temporary) / "prepared"
|
|
1776
|
+
try:
|
|
1777
|
+
_prepare_native_task(
|
|
1778
|
+
task_dir=task_dir,
|
|
1779
|
+
destination=prepared,
|
|
1780
|
+
harness=harness,
|
|
1781
|
+
version=version,
|
|
1782
|
+
limits=limits,
|
|
1783
|
+
resolved=_NativePreparation(admission, expected, base, grader),
|
|
1784
|
+
)
|
|
1785
|
+
except BaseException as error:
|
|
1786
|
+
try:
|
|
1787
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
1788
|
+
if (prepared / "artifacts").exists():
|
|
1789
|
+
shutil.copytree(
|
|
1790
|
+
prepared / "artifacts", destination / "artifacts", symlinks=True
|
|
1791
|
+
)
|
|
1792
|
+
codex.write_json(
|
|
1793
|
+
destination / "artifacts/preparation-failure.json",
|
|
1794
|
+
{
|
|
1795
|
+
"status": "failed",
|
|
1796
|
+
"error": str(error),
|
|
1797
|
+
"preparation_key": key,
|
|
1798
|
+
"inputs": inputs,
|
|
1799
|
+
},
|
|
1800
|
+
)
|
|
1801
|
+
except (OSError, ValueError) as evidence_error:
|
|
1802
|
+
error.add_note(
|
|
1803
|
+
"Could not preserve native preparation failure evidence: "
|
|
1804
|
+
+ str(evidence_error)
|
|
1805
|
+
)
|
|
1806
|
+
raise
|
|
1807
|
+
manifest = _native_asset_inventory(prepared)
|
|
1808
|
+
codex.write_json(prepared / "cache.json", {"inputs": inputs, "files": manifest})
|
|
1809
|
+
prepared.rename(cached)
|
|
1810
|
+
saved = cached, _cached_preparation(cached, contract, expected)
|
|
1811
|
+
assert_prepared_images(saved[1], expected, label=f"Prepared cache entry {cached}")
|
|
1812
|
+
cached, receipt = saved
|
|
1813
|
+
shutil.copytree(cached, destination)
|
|
1814
|
+
# Docker work for this run uses the runtime it runs on; the entry's is provenance.
|
|
1815
|
+
receipt.update(
|
|
1816
|
+
task_dir=str((destination / "task").resolve()),
|
|
1817
|
+
fixture_dir=str((destination / "fixture").resolve()),
|
|
1818
|
+
preparation_key=cached.name,
|
|
1819
|
+
isolation=expected,
|
|
1820
|
+
preparation_isolation=receipt["isolation"],
|
|
1821
|
+
)
|
|
1822
|
+
receipt["preparation_identity"] = {
|
|
1823
|
+
name: value
|
|
1824
|
+
for name, value in receipt.items()
|
|
1825
|
+
if name not in {"task_dir", "fixture_dir", "cleanup", "preparation_identity"}
|
|
1826
|
+
}
|
|
1827
|
+
codex.write_json(destination / "artifacts/native-preparation.json", receipt)
|
|
1828
|
+
return receipt
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def _preparation_contract(inputs: dict[str, Any]) -> dict[str, Any]:
|
|
1832
|
+
return {**inputs, "runtime": runtime_contract(_native_object(inputs["runtime"], "runtime"))}
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _cached_preparation(
|
|
1836
|
+
cached: Path, contract: dict[str, Any], expected: dict[str, object]
|
|
1837
|
+
) -> dict[str, Any]:
|
|
1838
|
+
"""The receipt of a complete cache entry prepared from equivalent inputs; raise if not."""
|
|
1839
|
+
packet = _native_object(json.loads((cached / "cache.json").read_text()), "native cache packet")
|
|
1840
|
+
if _preparation_contract(_native_object(packet["inputs"], "native cache inputs")) != contract:
|
|
1841
|
+
raise ValueError("Native preparation cache inputs changed")
|
|
1842
|
+
files = _native_object(packet["files"], "native cache files")
|
|
1843
|
+
if _native_asset_inventory(cached, exclude="cache.json") != files:
|
|
1844
|
+
raise ValueError("Native preparation cache evidence changed")
|
|
1845
|
+
receipt = _native_object(
|
|
1846
|
+
json.loads((cached / "artifacts/native-preparation.json").read_text()),
|
|
1847
|
+
"cached native preparation",
|
|
1848
|
+
)
|
|
1849
|
+
for name in ("image_identity", "grader_image_identity"):
|
|
1850
|
+
image = _native_object(receipt.get(name), "cached native image")
|
|
1851
|
+
if not all(isinstance(image.get(key), str) for key in ("image", "image_id")):
|
|
1852
|
+
raise ValueError("Native preparation cache has incomplete or invalid proof")
|
|
1853
|
+
if (
|
|
1854
|
+
receipt["status"] != "complete"
|
|
1855
|
+
or _native_object(receipt["cleanup"], "cached cleanup")["status"] != "complete"
|
|
1856
|
+
or runtime_contract(_native_object(receipt["isolation"], "cached runtime"))
|
|
1857
|
+
!= runtime_contract(expected)
|
|
1858
|
+
or inspect_snapshot(cached / "fixture")["digest"] != receipt["source_digest"]
|
|
1859
|
+
or native_task_assets(cached / "task") != receipt["task_assets_sha256"]
|
|
1860
|
+
):
|
|
1861
|
+
raise ValueError("Native preparation cache has incomplete or invalid proof")
|
|
1862
|
+
return receipt
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _saved_preparation(
|
|
1866
|
+
cache: Path, contract: dict[str, Any], expected: dict[str, object], held: ExitStack
|
|
1867
|
+
) -> tuple[Path, dict[str, Any]] | None:
|
|
1868
|
+
"""An entry saved under another key, as by 942f12c, whose inputs are equivalent.
|
|
1869
|
+
|
|
1870
|
+
Candidates are tried in name order. One that is in use, invalid, or names source this
|
|
1871
|
+
build does not know is skipped: it is not this preparation. So is one whose images the
|
|
1872
|
+
current Docker engine no longer holds; if no candidate is usable for that reason, the
|
|
1873
|
+
first such error is raised. The chosen entry stays share-locked until `held` closes.
|
|
1874
|
+
"""
|
|
1875
|
+
unavailable: ValueError | None = None
|
|
1876
|
+
for candidate in sorted(cache.iterdir()):
|
|
1877
|
+
lock_path = cache / (candidate.name + ".lock")
|
|
1878
|
+
if (
|
|
1879
|
+
candidate.name.startswith(".")
|
|
1880
|
+
or candidate.suffix == ".lock"
|
|
1881
|
+
or candidate.is_symlink()
|
|
1882
|
+
or not candidate.is_dir()
|
|
1883
|
+
or lock_path.is_symlink()
|
|
1884
|
+
):
|
|
1885
|
+
continue
|
|
1886
|
+
try:
|
|
1887
|
+
lock = lock_path.open("a")
|
|
1888
|
+
except OSError:
|
|
1889
|
+
continue
|
|
1890
|
+
kept = False
|
|
1891
|
+
try:
|
|
1892
|
+
try:
|
|
1893
|
+
fcntl.flock(lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
1894
|
+
receipt = _cached_preparation(candidate, contract, expected)
|
|
1895
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
1896
|
+
continue
|
|
1897
|
+
try:
|
|
1898
|
+
assert_prepared_images(
|
|
1899
|
+
receipt, expected, label=f"Prepared cache entry {candidate}"
|
|
1900
|
+
)
|
|
1901
|
+
except ValueError as error:
|
|
1902
|
+
unavailable = unavailable or error
|
|
1903
|
+
continue
|
|
1904
|
+
held.enter_context(lock)
|
|
1905
|
+
kept = True # `held` closes it from now on.
|
|
1906
|
+
return candidate, receipt
|
|
1907
|
+
finally:
|
|
1908
|
+
if not kept:
|
|
1909
|
+
lock.close()
|
|
1910
|
+
if unavailable is not None:
|
|
1911
|
+
raise unavailable
|
|
1912
|
+
return None
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
# The neutral git identity PluginBench supplies to a task whose image and environment set none.
|
|
1916
|
+
SUPPLIED_GIT_IDENTITY = {"name": "PluginBench Agent", "email": "agent@pluginbench.invalid"}
|
|
1917
|
+
# The probe's exit status when git has no author or committer identity (git itself uses 1 and 128).
|
|
1918
|
+
_NO_GIT_IDENTITY_EXIT = "3"
|
|
1919
|
+
# The same, in a task that already sets the variable the supplied identity travels in.
|
|
1920
|
+
_GIT_PARAMETERS_SET_EXIT = "4"
|
|
1921
|
+
|
|
1922
|
+
|
|
1923
|
+
def supplied_git_env(identity: dict[str, object]) -> dict[str, str]:
|
|
1924
|
+
"""The agent environment variables that carry the git identity PluginBench supplied, if any.
|
|
1925
|
+
|
|
1926
|
+
A task with its own identity has no `supplied_git_identity` and gets nothing. The probe and
|
|
1927
|
+
every agent launch take the identity from here, so they set the same variables. The probe
|
|
1928
|
+
supplies an identity only to a task that does not set GIT_CONFIG_PARAMETERS itself, so this
|
|
1929
|
+
never replaces a task's own git settings.
|
|
1930
|
+
"""
|
|
1931
|
+
if "supplied_git_identity" not in identity:
|
|
1932
|
+
return {}
|
|
1933
|
+
supplied = _native_object(identity["supplied_git_identity"], "supplied git identity")
|
|
1934
|
+
# The variable git uses to pass `git -c` settings, in the quoted `key=value` form every git
|
|
1935
|
+
# version reads. Not GIT_CONFIG_KEY_n or GIT_AUTHOR_*: Harbor treats a name containing KEY or
|
|
1936
|
+
# AUTH as a secret and redacts its value from all trial output.
|
|
1937
|
+
return {
|
|
1938
|
+
"GIT_CONFIG_PARAMETERS": f"'user.name={supplied['name']}' 'user.email={supplied['email']}'"
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
def probe_coding_environment(identity: dict[str, object]) -> dict[str, object]:
|
|
1943
|
+
"""No-model proof that an agent can commit in the frozen coding image.
|
|
1944
|
+
|
|
1945
|
+
Graded patches are `git diff BASE HEAD`, so an image where `git commit` fails makes every
|
|
1946
|
+
attempt an empty patch. The first probe uses only the task's own environment and agent user,
|
|
1947
|
+
exactly as Harbor starts the agent. Only when git there has no identity does a second probe
|
|
1948
|
+
add PluginBench's neutral identity; the receipt then names it in `supplied_git_identity`.
|
|
1949
|
+
"""
|
|
1950
|
+
receipt, exit_code = _git_probe(identity)
|
|
1951
|
+
if exit_code != _NO_GIT_IDENTITY_EXIT:
|
|
1952
|
+
return receipt
|
|
1953
|
+
supplied = dict(SUPPLIED_GIT_IDENTITY)
|
|
1954
|
+
second, _ = _git_probe({**identity, "supplied_git_identity": supplied})
|
|
1955
|
+
if second["status"] != "passed":
|
|
1956
|
+
second["reason"] = "with the git identity PluginBench supplied, " + str(second["reason"])
|
|
1957
|
+
return {**second, "supplied_git_identity": supplied, "task_identity_probe": receipt}
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def _git_probe(identity: dict[str, object]) -> tuple[dict[str, object], str | None]:
|
|
1961
|
+
"""One commit probe and the container's exit status (None if it did not run to the end)."""
|
|
1962
|
+
expected = _native_object(identity["isolation"], "probe runtime")
|
|
1963
|
+
limits = _native_object(expected["resources"], "probe resources")
|
|
1964
|
+
image = _native_object(identity["image_identity"], "probe coding image")
|
|
1965
|
+
task = importlib.import_module("harbor.models.task.task").Task(Path(str(identity["task_dir"])))
|
|
1966
|
+
resolve_env = importlib.import_module("harbor.utils.env").resolve_env_vars
|
|
1967
|
+
workdir = shlex.quote(str(identity["workdir"]))
|
|
1968
|
+
git = "git -c safe.directory=" + workdir
|
|
1969
|
+
supplied_env = supplied_git_env(identity)
|
|
1970
|
+
# Agent variables replace the task's own, so a task that sets GIT_CONFIG_PARAMETERS would
|
|
1971
|
+
# lose its settings to a supplied identity. The task-only probe reports that case apart.
|
|
1972
|
+
no_identity = f"exit {_NO_GIT_IDENTITY_EXIT}"
|
|
1973
|
+
if not supplied_env:
|
|
1974
|
+
no_identity = (
|
|
1975
|
+
f'test -z "${{GIT_CONFIG_PARAMETERS:-}}" || exit {_GIT_PARAMETERS_SET_EXIT}; '
|
|
1976
|
+
+ no_identity
|
|
1977
|
+
)
|
|
1978
|
+
# `git var` applies the same identity rules as `git commit`; its own message stays in the logs.
|
|
1979
|
+
command = (
|
|
1980
|
+
f"exec 2>&1; set -eu; cd {workdir}; before=$({git} rev-parse HEAD); "
|
|
1981
|
+
f"{git} var GIT_AUTHOR_IDENT >/dev/null && {git} var GIT_COMMITTER_IDENT >/dev/null "
|
|
1982
|
+
f"|| {{ {no_identity}; }}; "
|
|
1983
|
+
f"{git} commit --allow-empty --no-verify -q -m pluginbench-git-probe; "
|
|
1984
|
+
f'test "$({git} rev-parse HEAD)" != "$before"; {git} var GIT_AUTHOR_IDENT'
|
|
1985
|
+
)
|
|
1986
|
+
owner = uuid4().hex
|
|
1987
|
+
project = "pluginbench-git-probe-" + owner
|
|
1988
|
+
arguments = [
|
|
1989
|
+
"create",
|
|
1990
|
+
"--label",
|
|
1991
|
+
"com.docker.compose.project=" + project,
|
|
1992
|
+
"--label",
|
|
1993
|
+
_OWNER_LABEL + "=" + owner,
|
|
1994
|
+
"--cpus",
|
|
1995
|
+
str(limits["cpus"]),
|
|
1996
|
+
"--memory",
|
|
1997
|
+
str(limits["memory_mb"]) + "m",
|
|
1998
|
+
"--network",
|
|
1999
|
+
"none",
|
|
2000
|
+
"--entrypoint",
|
|
2001
|
+
"bash",
|
|
2002
|
+
]
|
|
2003
|
+
environment = {**resolve_env(task.config.environment.env), **supplied_env}
|
|
2004
|
+
for key, value in sorted(environment.items()):
|
|
2005
|
+
arguments.extend(["--env", f"{key}={value}"])
|
|
2006
|
+
if task.config.agent.user is not None:
|
|
2007
|
+
arguments.extend(["--user", str(task.config.agent.user)])
|
|
2008
|
+
receipt: dict[str, object] = {"status": "failed", "image_id": image["image_id"]}
|
|
2009
|
+
exit_code: str | None = None
|
|
2010
|
+
try:
|
|
2011
|
+
container = _docker(
|
|
2012
|
+
*arguments, str(image["image_id"]), "-c", command, expected_runtime=expected
|
|
2013
|
+
).strip()
|
|
2014
|
+
# Not `start --attach`: that exits with the container's status, and a failed commit
|
|
2015
|
+
# would then surface as "docker start failed" without the reason in the logs.
|
|
2016
|
+
_docker("start", container, expected_runtime=expected)
|
|
2017
|
+
exit_code = _docker("wait", container, timeout=60, expected_runtime=expected).strip()
|
|
2018
|
+
output = _docker("logs", container, expected_runtime=expected).strip()
|
|
2019
|
+
if exit_code == "0":
|
|
2020
|
+
receipt.update(status="passed", author=output.splitlines()[-1] if output else None)
|
|
2021
|
+
elif exit_code == _NO_GIT_IDENTITY_EXIT:
|
|
2022
|
+
receipt["reason"] = f"git has no author or committer identity in the coding image: {output}"
|
|
2023
|
+
elif exit_code == _GIT_PARAMETERS_SET_EXIT:
|
|
2024
|
+
receipt["reason"] = (
|
|
2025
|
+
"git has no author or committer identity in the coding image, and PluginBench "
|
|
2026
|
+
"cannot supply one because the task sets GIT_CONFIG_PARAMETERS. Add user.name "
|
|
2027
|
+
f"and user.email to the task's git settings: {output}"
|
|
2028
|
+
)
|
|
2029
|
+
else:
|
|
2030
|
+
receipt["reason"] = (
|
|
2031
|
+
f"git commit failed in the coding image (exit {exit_code}): {output}"
|
|
2032
|
+
)
|
|
2033
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2034
|
+
receipt["reason"] = str(error)
|
|
2035
|
+
finally:
|
|
2036
|
+
try:
|
|
2037
|
+
receipt["cleanup"] = cleanup(project=project, owner=owner, expected_runtime=expected)
|
|
2038
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2039
|
+
receipt["cleanup"] = {"status": "incomplete", "error": str(error)}
|
|
2040
|
+
receipt.update(status="failed", reason="git probe cleanup incomplete: " + str(error))
|
|
2041
|
+
exit_code = None
|
|
2042
|
+
return receipt, exit_code
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def binary_native_reward(verifier: object) -> bool | None:
|
|
2046
|
+
"""A native verifier's pass (1) or fail (0); None when its reward is not binary."""
|
|
2047
|
+
rewards = verifier.get("rewards") if isinstance(verifier, dict) else None
|
|
2048
|
+
if isinstance(rewards, dict) and ("reward" in rewards or len(rewards) == 1):
|
|
2049
|
+
reward = rewards["reward"] if "reward" in rewards else next(iter(rewards.values()))
|
|
2050
|
+
if type(reward) in {int, float} and reward in (0, 1):
|
|
2051
|
+
return bool(reward)
|
|
2052
|
+
return None
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
_REFERENCE_AGENT_TIMEOUT = 900
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def run_reference_agents(identity: dict[str, object], destination: Path) -> dict[str, object]:
|
|
2059
|
+
"""Run Harbor's oracle and nop agents through the frozen coding, patch and verifier path.
|
|
2060
|
+
|
|
2061
|
+
No credentials enter these trials (install-only environment), so no model can be called.
|
|
2062
|
+
Each trial's stopped source is exported and its graded patch reconstructed over the frozen
|
|
2063
|
+
fixture with the same functions paid attempts use, so a failure there blocks the check.
|
|
2064
|
+
The caller judges the results: oracle needs a nonempty patch and reward 1, nop reward 0.
|
|
2065
|
+
"""
|
|
2066
|
+
task_dir = Path(str(identity["task_dir"]))
|
|
2067
|
+
expected = _native_object(identity["isolation"], "reference runtime")
|
|
2068
|
+
toolchain = _native_object(identity["toolchain_identity"], "reference toolchain")
|
|
2069
|
+
solve = importlib.import_module("harbor.models.task.task").Task(task_dir).paths.solve_path
|
|
2070
|
+
agent_env = supplied_git_env(identity)
|
|
2071
|
+
results: dict[str, object] = {}
|
|
2072
|
+
for agent in ("oracle", "nop"):
|
|
2073
|
+
if agent == "oracle" and not solve.is_file():
|
|
2074
|
+
results[agent] = {
|
|
2075
|
+
"status": "not_configured",
|
|
2076
|
+
"reason": "task ships no solution/solve.sh",
|
|
2077
|
+
}
|
|
2078
|
+
continue
|
|
2079
|
+
area = destination / agent
|
|
2080
|
+
area.mkdir(parents=True)
|
|
2081
|
+
owner = uuid4().hex
|
|
2082
|
+
name = "pluginbench-" + owner
|
|
2083
|
+
config = _native_config(
|
|
2084
|
+
task_dir=task_dir,
|
|
2085
|
+
area=area,
|
|
2086
|
+
owner=owner,
|
|
2087
|
+
harness=str(toolchain["harness"]),
|
|
2088
|
+
version=str(toolchain["harness_version"]),
|
|
2089
|
+
limits=_native_object(expected["resources"], "reference resources"),
|
|
2090
|
+
timeout=_REFERENCE_AGENT_TIMEOUT,
|
|
2091
|
+
)
|
|
2092
|
+
# Harbor's built-in agents take no coding-CLI settings or provider hosts; the oracle
|
|
2093
|
+
# commits with the same git identity a coding agent gets.
|
|
2094
|
+
config["agent"] = {
|
|
2095
|
+
"name": agent,
|
|
2096
|
+
"override_timeout_sec": _REFERENCE_AGENT_TIMEOUT,
|
|
2097
|
+
"kwargs": {},
|
|
2098
|
+
**({"env": agent_env} if agent_env else {}),
|
|
2099
|
+
}
|
|
2100
|
+
row: dict[str, object] = {"status": "failed"}
|
|
2101
|
+
result: dict[str, object] = {}
|
|
2102
|
+
export: dict[str, object] | None = None
|
|
2103
|
+
try:
|
|
2104
|
+
worker = _native_process(
|
|
2105
|
+
area=area,
|
|
2106
|
+
config=config,
|
|
2107
|
+
expected=expected,
|
|
2108
|
+
harness=str(toolchain["harness"]),
|
|
2109
|
+
timeout=_native_worker_budget(
|
|
2110
|
+
task_dir=task_dir, agent_seconds=_REFERENCE_AGENT_TIMEOUT
|
|
2111
|
+
),
|
|
2112
|
+
install_only=True,
|
|
2113
|
+
)
|
|
2114
|
+
if worker.get("session_id") != name + "__env":
|
|
2115
|
+
raise ValueError("Reference agent returned a foreign trial identity")
|
|
2116
|
+
result = _native_object(worker["result"], "reference agent result")
|
|
2117
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2118
|
+
row["reason"] = str(error)
|
|
2119
|
+
finally:
|
|
2120
|
+
# Freeze the stopped source before cleanup, exactly as a paid attempt does.
|
|
2121
|
+
try:
|
|
2122
|
+
export = _native_submission(
|
|
2123
|
+
area=area, name=name, identity=identity, expected=expected
|
|
2124
|
+
)
|
|
2125
|
+
codex.write_json(area / "artifacts/submission-source.json", export)
|
|
2126
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2127
|
+
row.setdefault("reason", "stopped-source export failed: " + str(error))
|
|
2128
|
+
row["cleanup"] = _native_cleanup_projects(name=name, owner=owner, expected=expected)
|
|
2129
|
+
if export is not None and "reason" not in row:
|
|
2130
|
+
try:
|
|
2131
|
+
row.update(
|
|
2132
|
+
status="complete",
|
|
2133
|
+
exception_info=result.get("exception_info"),
|
|
2134
|
+
verifier_result=result.get("verifier_result"),
|
|
2135
|
+
**_admit_reference_submission(
|
|
2136
|
+
area=area, name=name, identity=identity, export=export
|
|
2137
|
+
),
|
|
2138
|
+
)
|
|
2139
|
+
except (OSError, ValueError) as error:
|
|
2140
|
+
row.update(status="failed", reason="graded patch admission failed: " + str(error))
|
|
2141
|
+
codex.write_json(area / "reference-agent.json", row)
|
|
2142
|
+
results[agent] = row
|
|
2143
|
+
return results
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
def _admit_reference_submission(
|
|
2147
|
+
*, area: Path, name: str, identity: dict[str, object], export: dict[str, object]
|
|
2148
|
+
) -> dict[str, object]:
|
|
2149
|
+
"""Admit a reference trial's export and graded patch as the study admits a paid attempt's."""
|
|
2150
|
+
artifacts = area / "artifacts"
|
|
2151
|
+
(artifacts / "harbor" / name).rename(artifacts / "harbor/trial")
|
|
2152
|
+
execution: dict[str, Any] = {
|
|
2153
|
+
"submission_export_digest": export["snapshot_digest"],
|
|
2154
|
+
"native_artifact_hashes": _native_artifact_hashes(artifacts / "harbor/trial/artifacts"),
|
|
2155
|
+
}
|
|
2156
|
+
submissions.validate_export(
|
|
2157
|
+
diagnostic=area / "workspace", artifacts=artifacts, execution=execution
|
|
2158
|
+
)
|
|
2159
|
+
patch = artifacts / "harbor/trial/artifacts" / submissions.PATCH_ARTIFACT
|
|
2160
|
+
qualification = identity.get("source_qualification")
|
|
2161
|
+
if identity.get("source_policy") != submissions.SOURCE_POLICY or not (
|
|
2162
|
+
isinstance(qualification, dict) and qualification.get("status") == "complete"
|
|
2163
|
+
):
|
|
2164
|
+
# The submission_source readiness row already reports why; paid attempts fail the same way.
|
|
2165
|
+
reconstruction: dict[str, object] = {
|
|
2166
|
+
"status": "skipped",
|
|
2167
|
+
"reason": "submission source is not qualified",
|
|
2168
|
+
}
|
|
2169
|
+
else:
|
|
2170
|
+
reconstruction = submissions.reconstruct(
|
|
2171
|
+
before=Path(str(identity["fixture_dir"])),
|
|
2172
|
+
task_dir=Path(str(identity["task_dir"])),
|
|
2173
|
+
identity=identity,
|
|
2174
|
+
artifacts=artifacts,
|
|
2175
|
+
execution=execution,
|
|
2176
|
+
destination=area / "snapshot",
|
|
2177
|
+
)
|
|
2178
|
+
return {
|
|
2179
|
+
"patch_bytes": patch.stat().st_size if patch.is_file() else None,
|
|
2180
|
+
"export_digest": export["snapshot_digest"],
|
|
2181
|
+
"reconstruction": reconstruction,
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
def _native_cleanup_projects(
|
|
2186
|
+
*, name: str, owner: str, expected: dict[str, object]
|
|
2187
|
+
) -> dict[str, object]:
|
|
2188
|
+
projects: dict[str, object] = {}
|
|
2189
|
+
for project in (name + "__env", name + "__verifier__trial"):
|
|
2190
|
+
try:
|
|
2191
|
+
projects[project] = cleanup(project=project, owner=owner, expected_runtime=expected)
|
|
2192
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2193
|
+
projects[project] = {"status": "incomplete", "error": str(error)}
|
|
2194
|
+
return {
|
|
2195
|
+
"status": "complete"
|
|
2196
|
+
if all(
|
|
2197
|
+
_native_object(value, "native cleanup")["status"] == "complete"
|
|
2198
|
+
for value in projects.values()
|
|
2199
|
+
)
|
|
2200
|
+
else "incomplete",
|
|
2201
|
+
"projects": projects,
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
def _native_submission(
|
|
2206
|
+
*, area: Path, name: str, identity: dict[str, object], expected: dict[str, object]
|
|
2207
|
+
) -> dict[str, object]:
|
|
2208
|
+
receipt_path = area / "artifacts/submission-source.json"
|
|
2209
|
+
if receipt_path.is_file():
|
|
2210
|
+
receipt = _native_object(json.loads(receipt_path.read_text()), "native submission receipt")
|
|
2211
|
+
else:
|
|
2212
|
+
image = _native_object(identity["image_identity"], "native coding image")
|
|
2213
|
+
container = _native_container(
|
|
2214
|
+
project=name + "__env",
|
|
2215
|
+
trial_dir=area / "artifacts/harbor" / name,
|
|
2216
|
+
expected=expected,
|
|
2217
|
+
image_id=str(image["image_id"]),
|
|
2218
|
+
)
|
|
2219
|
+
_docker("stop", "--time", "10", str(container["Id"]), expected_runtime=expected)
|
|
2220
|
+
receipt = export_native_source(
|
|
2221
|
+
destination=area / "workspace",
|
|
2222
|
+
project=name + "__env",
|
|
2223
|
+
trial_dir=area / "artifacts/harbor" / name,
|
|
2224
|
+
workdir=str(identity["workdir"]),
|
|
2225
|
+
expected=expected,
|
|
2226
|
+
image_id=str(image["image_id"]),
|
|
2227
|
+
fresh_source=True,
|
|
2228
|
+
agent_workspace=True,
|
|
2229
|
+
)
|
|
2230
|
+
if inspect_snapshot(area / "workspace")["digest"] != receipt["snapshot_digest"]:
|
|
2231
|
+
raise ValueError("Frozen native submission changed before measurement")
|
|
2232
|
+
if (
|
|
2233
|
+
receipt.get("source_metadata_digest") is not None
|
|
2234
|
+
and native_task_assets(area / "workspace") != receipt["source_metadata_digest"]
|
|
2235
|
+
):
|
|
2236
|
+
raise ValueError("Frozen native submission metadata changed before measurement")
|
|
2237
|
+
if identity.get("preparation_policy") == _PREPARATION_POLICY and not isinstance(
|
|
2238
|
+
receipt.get("source_metadata_digest"), str
|
|
2239
|
+
):
|
|
2240
|
+
raise ValueError("Frozen native submission metadata proof unavailable")
|
|
2241
|
+
return receipt
|
|
2242
|
+
|
|
2243
|
+
|
|
2244
|
+
def _native_completion(
|
|
2245
|
+
*,
|
|
2246
|
+
area: Path,
|
|
2247
|
+
name: str,
|
|
2248
|
+
execution: dict[str, object],
|
|
2249
|
+
native_result: dict[str, object],
|
|
2250
|
+
receipt: dict[str, object],
|
|
2251
|
+
launch: dict[str, object],
|
|
2252
|
+
failure: BaseException | None,
|
|
2253
|
+
started: float,
|
|
2254
|
+
) -> None:
|
|
2255
|
+
artifacts = area / "artifacts"
|
|
2256
|
+
host = str(launch["harness"]).removesuffix("-cli")
|
|
2257
|
+
trial_dir = artifacts / "harbor" / name
|
|
2258
|
+
if trial_dir.exists():
|
|
2259
|
+
trial_dir.rename(artifacts / "harbor/trial")
|
|
2260
|
+
if _native_object(receipt["cleanup"], "native cleanup")["status"] == "complete":
|
|
2261
|
+
try:
|
|
2262
|
+
execution.update(
|
|
2263
|
+
_native_evidence(
|
|
2264
|
+
area=area,
|
|
2265
|
+
host=host,
|
|
2266
|
+
effort=str(launch["requested_effort"]),
|
|
2267
|
+
transport=_CODEX_TRANSPORT_VERSION if host == "codex" else None,
|
|
2268
|
+
include_descendants=bool(
|
|
2269
|
+
_native_object(launch.get("capabilities", {}), "coding capabilities").get("subagents")
|
|
2270
|
+
),
|
|
2271
|
+
)
|
|
2272
|
+
)
|
|
2273
|
+
except (OSError, ValueError) as error:
|
|
2274
|
+
execution["evidence_error"] = str(error)
|
|
2275
|
+
native_error = native_result.get("exception_info")
|
|
2276
|
+
if native_error is not None:
|
|
2277
|
+
execution["error"] = native_error
|
|
2278
|
+
execution.update(
|
|
2279
|
+
native_result=native_result,
|
|
2280
|
+
native_verifier_result=native_result.get("verifier_result"),
|
|
2281
|
+
native_artifact_hashes=_native_artifact_hashes(artifacts / "harbor/trial/artifacts"),
|
|
2282
|
+
timed_out=isinstance(failure, subprocess.TimeoutExpired)
|
|
2283
|
+
or (
|
|
2284
|
+
isinstance(native_error, dict)
|
|
2285
|
+
and native_error.get("exception_type") == "AgentTimeoutError"
|
|
2286
|
+
),
|
|
2287
|
+
elapsed_seconds=time.monotonic() - started,
|
|
2288
|
+
private_runtime_removed=not (area / "private").exists(),
|
|
2289
|
+
launch=launch,
|
|
2290
|
+
launch_harness_version=codex.observed_cli_version(execution.get("startup"), host=host),
|
|
2291
|
+
)
|
|
2292
|
+
if launch.get("delivery") == native_treatments.DELIVERY_POLICY:
|
|
2293
|
+
try:
|
|
2294
|
+
treatment_receipt = _native_object(
|
|
2295
|
+
json.loads((artifacts / "native-treatment.json").read_text()), "native treatment receipt"
|
|
2296
|
+
)
|
|
2297
|
+
if (treatment_receipt.get("status") != "verified"
|
|
2298
|
+
or treatment_receipt.get("inventory") != launch.get("treatment_inventory")):
|
|
2299
|
+
raise ValueError("native treatment installation was not verified for this attempt")
|
|
2300
|
+
execution["native_treatment"] = treatment_receipt
|
|
2301
|
+
# Command hooks may execute subprocesses outside native session logging.
|
|
2302
|
+
# Preserve native observed usage, but never present it as complete total cost.
|
|
2303
|
+
if _native_object(treatment_receipt.get("hooks"), "native hooks").get("hooks"):
|
|
2304
|
+
execution["native_session_usage"] = execution.get("usage")
|
|
2305
|
+
execution["usage"] = None
|
|
2306
|
+
execution["usage_accounting"] = {
|
|
2307
|
+
"status": "incomplete",
|
|
2308
|
+
"reason": "command-hook subprocess usage is not independently metered",
|
|
2309
|
+
"native_sessions": execution.get("usage_accounting"),
|
|
2310
|
+
}
|
|
2311
|
+
except (OSError, ValueError) as error:
|
|
2312
|
+
execution["treatment_error"] = str(error)
|
|
2313
|
+
execution["successful_native_completion"] = False
|
|
2314
|
+
if failure is not None or native_error is not None:
|
|
2315
|
+
execution["successful_native_completion"] = False
|
|
2316
|
+
execution["exit_code"] = 0 if execution["successful_native_completion"] else 1
|
|
2317
|
+
codex.write_json(artifacts / "runtime-isolation.json", receipt)
|
|
2318
|
+
codex.write_json(artifacts / "execution.json", execution)
|
|
2319
|
+
|
|
2320
|
+
|
|
2321
|
+
def invoke_native(
|
|
2322
|
+
*,
|
|
2323
|
+
area: Path,
|
|
2324
|
+
task_dir: Path,
|
|
2325
|
+
entry: dict[str, object],
|
|
2326
|
+
protocol: dict[str, object],
|
|
2327
|
+
prompt: str,
|
|
2328
|
+
timeout: int,
|
|
2329
|
+
) -> dict[str, object]:
|
|
2330
|
+
"""Run the frozen standard task and its native separate verifier."""
|
|
2331
|
+
identity = _native_object(protocol.get("native_task_identity"), "prepared native task")
|
|
2332
|
+
host = str(entry["host"])
|
|
2333
|
+
treatment = Treatment.model_validate(protocol.get("treatment", {}))
|
|
2334
|
+
capabilities = NativeCapabilities.model_validate(protocol.get("capabilities", {}))
|
|
2335
|
+
native_treatments.admit(treatment, host)
|
|
2336
|
+
if prompt:
|
|
2337
|
+
raise ValueError("native coding task prompts must not contain treatment text")
|
|
2338
|
+
toolchain = _native_object(identity["toolchain_identity"], "native toolchain")
|
|
2339
|
+
if (
|
|
2340
|
+
toolchain["harness"] != host
|
|
2341
|
+
or toolchain["harness_version"] != _normalize_version(str(protocol["harness_version"]))
|
|
2342
|
+
or identity.get("transport_policy")
|
|
2343
|
+
!= {"name": NATIVE_TRANSPORT_POLICY, "provider_hosts": NATIVE_PROVIDER_HOSTS.get(host)}
|
|
2344
|
+
):
|
|
2345
|
+
raise ValueError(
|
|
2346
|
+
"Native task was prepared for different harness or model transport settings"
|
|
2347
|
+
)
|
|
2348
|
+
if native_task_assets(task_dir) != identity["task_assets_sha256"]:
|
|
2349
|
+
raise ValueError("Frozen native task assets changed")
|
|
2350
|
+
validate_native_task(task_dir)
|
|
2351
|
+
expected = assert_runtime(_native_object(protocol["isolation"], "native runtime"))
|
|
2352
|
+
image = _native_object(identity["image_identity"], "native coding image")
|
|
2353
|
+
grader = _native_object(identity["grader_image_identity"], "native grader image")
|
|
2354
|
+
for frozen_image in (image, grader):
|
|
2355
|
+
_assert_native_image(frozen_image, expected)
|
|
2356
|
+
artifacts = area / "artifacts"
|
|
2357
|
+
artifacts.mkdir(exist_ok=True)
|
|
2358
|
+
if (artifacts / "invocation.json").exists():
|
|
2359
|
+
raise ValueError("Native invocation already started; use a fresh area")
|
|
2360
|
+
# Existing study setup copies the fixture; hooks supply the actual before/after.
|
|
2361
|
+
for name in ("before", "workspace"):
|
|
2362
|
+
path = area / name
|
|
2363
|
+
if path.exists():
|
|
2364
|
+
if inspect_snapshot(path)["digest"] != identity["source_digest"]:
|
|
2365
|
+
raise ValueError("Native trial area does not match the prepared fixture")
|
|
2366
|
+
shutil.rmtree(path)
|
|
2367
|
+
owner = uuid4().hex
|
|
2368
|
+
name = "pluginbench-" + owner
|
|
2369
|
+
effort = str(protocol["reasoning"])
|
|
2370
|
+
version = _normalize_version(str(protocol["harness_version"]))
|
|
2371
|
+
models = _native_object(protocol["models"], "native models")
|
|
2372
|
+
limits = _native_object(expected["resources"], "native resources")
|
|
2373
|
+
config = _native_config(
|
|
2374
|
+
task_dir=task_dir,
|
|
2375
|
+
area=area,
|
|
2376
|
+
owner=owner,
|
|
2377
|
+
harness=host,
|
|
2378
|
+
version=version,
|
|
2379
|
+
limits=limits,
|
|
2380
|
+
timeout=timeout,
|
|
2381
|
+
model=str(models[host]),
|
|
2382
|
+
effort=effort,
|
|
2383
|
+
prompt=prompt,
|
|
2384
|
+
treatment=treatment,
|
|
2385
|
+
capabilities=capabilities,
|
|
2386
|
+
agent_env=supplied_git_env(identity),
|
|
2387
|
+
)
|
|
2388
|
+
config["pluginbench_boundary"] = {
|
|
2389
|
+
"treatment": treatment.model_dump(mode="json"),
|
|
2390
|
+
"capabilities": capabilities.model_dump(mode="json"),
|
|
2391
|
+
"area": str(area.resolve()),
|
|
2392
|
+
"runtime": expected,
|
|
2393
|
+
"image": image,
|
|
2394
|
+
"source_digest": identity["source_digest"],
|
|
2395
|
+
"source_metadata_digest": _native_object(
|
|
2396
|
+
identity.get("source_qualification", {}), "source qualification"
|
|
2397
|
+
).get("fixture_metadata_digest"),
|
|
2398
|
+
"harness": host,
|
|
2399
|
+
"version": version,
|
|
2400
|
+
}
|
|
2401
|
+
launch = {
|
|
2402
|
+
"harness": host + "-cli",
|
|
2403
|
+
"requested_model": models[host],
|
|
2404
|
+
"requested_effort": effort,
|
|
2405
|
+
"harness_version": version,
|
|
2406
|
+
"backend": PROTOCOL,
|
|
2407
|
+
"delivery": native_treatments.DELIVERY_POLICY,
|
|
2408
|
+
"capabilities": capabilities.model_dump(mode="json"),
|
|
2409
|
+
"treatment_inventory": native_treatments.admit(treatment, host),
|
|
2410
|
+
}
|
|
2411
|
+
codex.write_json(
|
|
2412
|
+
artifacts / "invocation.json",
|
|
2413
|
+
{
|
|
2414
|
+
**launch,
|
|
2415
|
+
"task_assets_sha256": identity["task_assets_sha256"],
|
|
2416
|
+
"native_task_identity": identity,
|
|
2417
|
+
# What this attempt ran with: vanilla cache admission checks its Docker client.
|
|
2418
|
+
"runtime": expected,
|
|
2419
|
+
"timeout_seconds": timeout,
|
|
2420
|
+
"delivery": native_treatments.DELIVERY_POLICY,
|
|
2421
|
+
"capabilities": capabilities.model_dump(mode="json"),
|
|
2422
|
+
"treatment_inventory": native_treatments.admit(treatment, host),
|
|
2423
|
+
},
|
|
2424
|
+
)
|
|
2425
|
+
(artifacts / "prompt.txt").write_text(prompt)
|
|
2426
|
+
started = time.monotonic()
|
|
2427
|
+
execution: dict[str, object] = {
|
|
2428
|
+
**codex.parse_events("", [], host, requested_effort=effort),
|
|
2429
|
+
"submission_collected": False,
|
|
2430
|
+
"successful_native_completion": False,
|
|
2431
|
+
}
|
|
2432
|
+
receipt: dict[str, object] = {
|
|
2433
|
+
"export": {"status": "incomplete"},
|
|
2434
|
+
"cleanup": {"status": "incomplete"},
|
|
2435
|
+
}
|
|
2436
|
+
native_result: dict[str, object] = {}
|
|
2437
|
+
failure: BaseException | None = None
|
|
2438
|
+
try:
|
|
2439
|
+
worker = _native_process(
|
|
2440
|
+
area=area,
|
|
2441
|
+
config=config,
|
|
2442
|
+
expected=expected,
|
|
2443
|
+
harness=host,
|
|
2444
|
+
timeout=_native_worker_budget(task_dir=task_dir, agent_seconds=timeout),
|
|
2445
|
+
install_only=False,
|
|
2446
|
+
auth_file=Path(str(protocol["auth_file"])) if protocol.get("auth_file") else None,
|
|
2447
|
+
)
|
|
2448
|
+
if worker.get("session_id") != name + "__env":
|
|
2449
|
+
raise ValueError("Native worker returned a foreign project identity")
|
|
2450
|
+
native_result = _native_object(worker["result"], "native trial result")
|
|
2451
|
+
if native_result.get("verifier_result") is not None:
|
|
2452
|
+
verifier_container = _native_container(
|
|
2453
|
+
project=name + "__verifier__trial",
|
|
2454
|
+
trial_dir=artifacts / "harbor" / name,
|
|
2455
|
+
expected=expected,
|
|
2456
|
+
image_id=str(grader["image_id"]),
|
|
2457
|
+
)
|
|
2458
|
+
receipt["verifier"] = {
|
|
2459
|
+
"container_id": verifier_container["Id"],
|
|
2460
|
+
"image_id": verifier_container["Image"],
|
|
2461
|
+
"state": stopped_container_state(verifier_container.get("State")),
|
|
2462
|
+
"resources": verifier_container["HostConfig"],
|
|
2463
|
+
}
|
|
2464
|
+
except BaseException as error:
|
|
2465
|
+
failure = error
|
|
2466
|
+
execution["error"] = f"{type(error).__name__}: {error}"
|
|
2467
|
+
finally:
|
|
2468
|
+
try:
|
|
2469
|
+
receipt["export"] = _native_submission(
|
|
2470
|
+
area=area, name=name, identity=identity, expected=expected
|
|
2471
|
+
)
|
|
2472
|
+
execution.update(
|
|
2473
|
+
submission_collected=True,
|
|
2474
|
+
submission_export_digest=_native_object(receipt["export"], "native export")[
|
|
2475
|
+
"snapshot_digest"
|
|
2476
|
+
],
|
|
2477
|
+
)
|
|
2478
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2479
|
+
execution["submission_error"] = str(error)
|
|
2480
|
+
receipt["cleanup"] = _native_cleanup_projects(name=name, owner=owner, expected=expected)
|
|
2481
|
+
if _native_object(receipt["cleanup"], "native cleanup")["status"] != "complete":
|
|
2482
|
+
execution["submission_collected"] = False
|
|
2483
|
+
cleanup_error = ValueError(
|
|
2484
|
+
"Native cleanup incomplete: " + json.dumps(receipt["cleanup"])
|
|
2485
|
+
)
|
|
2486
|
+
if failure is None:
|
|
2487
|
+
failure = cleanup_error
|
|
2488
|
+
else:
|
|
2489
|
+
failure.add_note(str(cleanup_error))
|
|
2490
|
+
_native_completion(
|
|
2491
|
+
area=area,
|
|
2492
|
+
name=name,
|
|
2493
|
+
execution=execution,
|
|
2494
|
+
native_result=native_result,
|
|
2495
|
+
receipt=receipt,
|
|
2496
|
+
launch=launch,
|
|
2497
|
+
failure=failure,
|
|
2498
|
+
started=started,
|
|
2499
|
+
)
|
|
2500
|
+
if failure is not None:
|
|
2501
|
+
raise failure
|
|
2502
|
+
return execution
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
def run_native_command(
|
|
2506
|
+
*,
|
|
2507
|
+
snapshot: Path,
|
|
2508
|
+
task_identity: dict[str, object],
|
|
2509
|
+
destination: Path,
|
|
2510
|
+
command: list[str],
|
|
2511
|
+
report_path: str,
|
|
2512
|
+
timeout: int,
|
|
2513
|
+
) -> dict[str, object]:
|
|
2514
|
+
"""Run explicitly configured measurement commands in a fresh frozen image."""
|
|
2515
|
+
relative = Path(report_path)
|
|
2516
|
+
if (
|
|
2517
|
+
not command
|
|
2518
|
+
or any(not isinstance(item, str) or not item for item in command)
|
|
2519
|
+
or relative.is_absolute()
|
|
2520
|
+
or ".." in relative.parts
|
|
2521
|
+
or not relative.parts
|
|
2522
|
+
or timeout < 1
|
|
2523
|
+
):
|
|
2524
|
+
raise ValueError(
|
|
2525
|
+
"Native measurement requires argv, a safe relative report path and a positive timeout"
|
|
2526
|
+
)
|
|
2527
|
+
original = inspect_snapshot(snapshot)
|
|
2528
|
+
original_inventory = _native_asset_inventory(snapshot)
|
|
2529
|
+
if relative.as_posix() in original_inventory or _is_source_path(relative.as_posix()):
|
|
2530
|
+
raise ValueError(
|
|
2531
|
+
"Native measurement report must not overwrite original inputs or source paths"
|
|
2532
|
+
)
|
|
2533
|
+
# Measurement starts no coding agent, so the launcher version does not bind it.
|
|
2534
|
+
expected = assert_runtime(
|
|
2535
|
+
_native_object(task_identity["isolation"], "measurement runtime"), launcher=False
|
|
2536
|
+
)
|
|
2537
|
+
image = _native_object(task_identity["image_identity"], "measurement image")
|
|
2538
|
+
workdir = str(task_identity["workdir"])
|
|
2539
|
+
limits = _native_object(expected["resources"], "measurement resources")
|
|
2540
|
+
owner = uuid4().hex
|
|
2541
|
+
project = "pluginbench-measure-" + owner
|
|
2542
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
2543
|
+
container_id = ""
|
|
2544
|
+
receipt: dict[str, object] = {
|
|
2545
|
+
"exit_code": None,
|
|
2546
|
+
"timed_out": False,
|
|
2547
|
+
"report_collected": False,
|
|
2548
|
+
"before_digest": original["digest"],
|
|
2549
|
+
"image_id": image["image_id"],
|
|
2550
|
+
"resources": limits,
|
|
2551
|
+
"command": command,
|
|
2552
|
+
"report_path": report_path,
|
|
2553
|
+
"cleanup": {"status": "incomplete"},
|
|
2554
|
+
}
|
|
2555
|
+
failure: BaseException | None = None
|
|
2556
|
+
|
|
2557
|
+
def docker(*arguments: str, limit: int = _DOCKER_TIMEOUT) -> str:
|
|
2558
|
+
return _docker(*arguments, timeout=limit, expected_runtime=expected)
|
|
2559
|
+
|
|
2560
|
+
try:
|
|
2561
|
+
container_id = docker(
|
|
2562
|
+
"create",
|
|
2563
|
+
"--label",
|
|
2564
|
+
"com.docker.compose.project=" + project,
|
|
2565
|
+
"--label",
|
|
2566
|
+
"com.docker.compose.service=main",
|
|
2567
|
+
"--label",
|
|
2568
|
+
_OWNER_LABEL + "=" + owner,
|
|
2569
|
+
"--cpus",
|
|
2570
|
+
str(limits["cpus"]),
|
|
2571
|
+
"--memory",
|
|
2572
|
+
str(limits["memory_mb"]) + "m",
|
|
2573
|
+
"--network",
|
|
2574
|
+
"none",
|
|
2575
|
+
"--workdir",
|
|
2576
|
+
workdir,
|
|
2577
|
+
"--entrypoint",
|
|
2578
|
+
"bash",
|
|
2579
|
+
str(image["image_id"]),
|
|
2580
|
+
"-c",
|
|
2581
|
+
"sleep infinity",
|
|
2582
|
+
).strip()
|
|
2583
|
+
docker("start", container_id)
|
|
2584
|
+
excluded = " -o ".join("-name " + shlex.quote(name) for name in sorted(EXCLUDED_NAMES))
|
|
2585
|
+
docker(
|
|
2586
|
+
"exec",
|
|
2587
|
+
container_id,
|
|
2588
|
+
"bash",
|
|
2589
|
+
"-c",
|
|
2590
|
+
"set -eu; find "
|
|
2591
|
+
+ shlex.quote(workdir)
|
|
2592
|
+
+ " -mindepth 1 \\( "
|
|
2593
|
+
+ excluded
|
|
2594
|
+
+ " \\) -prune -o \\( -type f -o -type l \\) -exec rm -f -- {} +; find "
|
|
2595
|
+
+ shlex.quote(workdir)
|
|
2596
|
+
+ " -mindepth 1 \\( "
|
|
2597
|
+
+ excluded
|
|
2598
|
+
+ ' \\) -prune -o -type d -print0 | sort -zr | while IFS= read -r -d \'\' directory; do if [ -z "$(ls -A -- "$directory")" ]; then rmdir -- "$directory"; fi; done',
|
|
2599
|
+
)
|
|
2600
|
+
docker("cp", str(snapshot) + "/.", container_id + ":" + workdir + "/", limit=300)
|
|
2601
|
+
before = export_native_source(
|
|
2602
|
+
destination=destination / "before",
|
|
2603
|
+
project=project,
|
|
2604
|
+
trial_dir=destination,
|
|
2605
|
+
workdir=workdir,
|
|
2606
|
+
expected=expected,
|
|
2607
|
+
image_id=str(image["image_id"]),
|
|
2608
|
+
resume=True,
|
|
2609
|
+
)
|
|
2610
|
+
if before["snapshot_digest"] != original["digest"]:
|
|
2611
|
+
raise ValueError("Native measurement source differs from the selected frozen snapshot")
|
|
2612
|
+
if _native_asset_inventory(destination / "before") != original_inventory:
|
|
2613
|
+
raise ValueError(
|
|
2614
|
+
"Native measurement source metadata differs from the selected frozen snapshot"
|
|
2615
|
+
)
|
|
2616
|
+
# A stale report must never be accepted as output of the requested command.
|
|
2617
|
+
target = workdir.rstrip("/") + "/" + report_path
|
|
2618
|
+
docker("exec", container_id, "bash", "-c", "rm -f -- " + shlex.quote(target))
|
|
2619
|
+
try:
|
|
2620
|
+
argv = [
|
|
2621
|
+
"docker",
|
|
2622
|
+
"--host",
|
|
2623
|
+
str(_native_object(expected["docker_engine"], "measurement engine")["endpoint"]),
|
|
2624
|
+
"exec",
|
|
2625
|
+
"--workdir",
|
|
2626
|
+
workdir,
|
|
2627
|
+
container_id,
|
|
2628
|
+
*command,
|
|
2629
|
+
]
|
|
2630
|
+
cli = _object(expected["docker_cli"], "measurement Docker CLI")
|
|
2631
|
+
argv[0] = _frozen_executable(cli, "CLI")["resolved_path"]
|
|
2632
|
+
result = run_command(
|
|
2633
|
+
argv,
|
|
2634
|
+
cwd=destination,
|
|
2635
|
+
env={
|
|
2636
|
+
key: value
|
|
2637
|
+
for key, value in os.environ.items()
|
|
2638
|
+
if key not in {"DOCKER_HOST", "DOCKER_CONTEXT"}
|
|
2639
|
+
},
|
|
2640
|
+
timeout=timeout,
|
|
2641
|
+
)
|
|
2642
|
+
receipt.update(exit_code=result.returncode, stdout=result.stdout, stderr=result.stderr)
|
|
2643
|
+
except subprocess.TimeoutExpired as error:
|
|
2644
|
+
receipt.update(
|
|
2645
|
+
timed_out=True,
|
|
2646
|
+
stdout=(error.output or b"").decode(errors="replace"),
|
|
2647
|
+
stderr=(error.stderr or b"").decode(errors="replace"),
|
|
2648
|
+
)
|
|
2649
|
+
docker("stop", "--time", "10", container_id)
|
|
2650
|
+
after = export_native_source(
|
|
2651
|
+
destination=destination / "after",
|
|
2652
|
+
project=project,
|
|
2653
|
+
trial_dir=destination,
|
|
2654
|
+
workdir=workdir,
|
|
2655
|
+
expected=expected,
|
|
2656
|
+
image_id=str(image["image_id"]),
|
|
2657
|
+
)
|
|
2658
|
+
receipt["after_digest"] = after["snapshot_digest"]
|
|
2659
|
+
_assert_measurement_source(
|
|
2660
|
+
original_inventory=original_inventory,
|
|
2661
|
+
after=destination / "after",
|
|
2662
|
+
report_path=report_path,
|
|
2663
|
+
)
|
|
2664
|
+
receipt["source_integrity"] = "verified"
|
|
2665
|
+
if not receipt["timed_out"]:
|
|
2666
|
+
report = destination / "after" / relative
|
|
2667
|
+
# Included-source export rejects all symlinks before reaching this read.
|
|
2668
|
+
if report.is_file():
|
|
2669
|
+
shutil.copyfile(report, destination / "report.raw")
|
|
2670
|
+
receipt.update(
|
|
2671
|
+
report_collected=True,
|
|
2672
|
+
report_sha256=hashlib.sha256(report.read_bytes()).hexdigest(),
|
|
2673
|
+
)
|
|
2674
|
+
except BaseException as error:
|
|
2675
|
+
failure = error
|
|
2676
|
+
receipt["error"] = str(error)
|
|
2677
|
+
receipt["source_integrity"] = "failed"
|
|
2678
|
+
finally:
|
|
2679
|
+
try:
|
|
2680
|
+
receipt["cleanup"] = cleanup(project=project, owner=owner, expected_runtime=expected)
|
|
2681
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
2682
|
+
receipt["cleanup"] = {"status": "incomplete", "error": str(error)}
|
|
2683
|
+
if failure is None:
|
|
2684
|
+
failure = error
|
|
2685
|
+
else:
|
|
2686
|
+
failure.add_note("Native measurement cleanup failed: " + str(error))
|
|
2687
|
+
if (
|
|
2688
|
+
inspect_snapshot(snapshot)["digest"] != original["digest"]
|
|
2689
|
+
or _native_asset_inventory(snapshot) != original_inventory
|
|
2690
|
+
):
|
|
2691
|
+
failure = ValueError("Frozen measurement input changed during execution")
|
|
2692
|
+
codex.write_json(destination / "execution.json", receipt)
|
|
2693
|
+
if failure is not None:
|
|
2694
|
+
raise failure
|
|
2695
|
+
return receipt
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
def _assert_measurement_source(
|
|
2699
|
+
*, original_inventory: dict[str, str], after: Path, report_path: str
|
|
2700
|
+
) -> None:
|
|
2701
|
+
actual = _native_asset_inventory(after)
|
|
2702
|
+
for name, digest in original_inventory.items():
|
|
2703
|
+
if actual.get(name) != digest:
|
|
2704
|
+
raise ValueError(
|
|
2705
|
+
"Native measurement changed selected source bytes or metadata: " + name
|
|
2706
|
+
)
|
|
2707
|
+
parent = Path(report_path).parent
|
|
2708
|
+
report_tree = parent.as_posix() + "/"
|
|
2709
|
+
generated_tree = parent != Path(".") and not any(
|
|
2710
|
+
name.startswith(report_tree) and (_is_source_path(name) or _is_test_path(name))
|
|
2711
|
+
for name in original_inventory
|
|
2712
|
+
if not name.endswith("/")
|
|
2713
|
+
)
|
|
2714
|
+
for name in actual.keys() - original_inventory.keys():
|
|
2715
|
+
if (
|
|
2716
|
+
name != report_path
|
|
2717
|
+
and not name.endswith("/")
|
|
2718
|
+
and not (generated_tree and name.startswith(report_tree))
|
|
2719
|
+
and (_is_source_path(name) or _is_test_path(name))
|
|
2720
|
+
):
|
|
2721
|
+
raise ValueError("Native measurement introduced production or test source: " + name)
|
|
2722
|
+
|
|
2723
|
+
|
|
2724
|
+
def _native_artifact_hashes(directory: Path) -> dict[str, str]:
|
|
2725
|
+
return {
|
|
2726
|
+
path.relative_to(directory).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
|
|
2727
|
+
for path in directory.rglob("*")
|
|
2728
|
+
if path.is_file() and not path.is_symlink()
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
|
|
2732
|
+
def _native_regrade_task(
|
|
2733
|
+
*, task_dir: Path, source: Path, directory: Path, artifact_hashes: dict[str, str]
|
|
2734
|
+
) -> tuple[Path, dict[str, object]]:
|
|
2735
|
+
"""Remove only redundant declarations whose bytes the convention parent captured."""
|
|
2736
|
+
if _native_artifact_hashes(source / "artifacts") != artifact_hashes:
|
|
2737
|
+
raise ValueError("Recorded native artifacts differ from their frozen hashes")
|
|
2738
|
+
task = importlib.import_module("harbor.models.task.task").Task(task_dir)
|
|
2739
|
+
helpers = importlib.import_module("harbor.models.task.artifacts")
|
|
2740
|
+
host_path = importlib.import_module("harbor.trial.artifact_handler").artifact_host_path
|
|
2741
|
+
convention = helpers.convention_source_for_os(task.config.environment.os)
|
|
2742
|
+
declared = helpers.normalize_artifact_entries(task.config.artifacts)
|
|
2743
|
+
parent = next(
|
|
2744
|
+
entry
|
|
2745
|
+
for entry in helpers.with_convention_entry(declared, convention_source=convention)
|
|
2746
|
+
if helpers.is_convention_entry(entry, convention)
|
|
2747
|
+
)
|
|
2748
|
+
kept = []
|
|
2749
|
+
removed: list[dict[str, object]] = []
|
|
2750
|
+
for entry in declared:
|
|
2751
|
+
suffix = (
|
|
2752
|
+
PurePosixPath(entry.source).relative_to(convention)
|
|
2753
|
+
if (PurePosixPath(convention) in PurePosixPath(entry.source).parents)
|
|
2754
|
+
else None
|
|
2755
|
+
)
|
|
2756
|
+
child = host_path(source / "artifacts", entry)
|
|
2757
|
+
if (
|
|
2758
|
+
suffix is None
|
|
2759
|
+
or parent.exclude
|
|
2760
|
+
or parent.destination is not None
|
|
2761
|
+
or entry.exclude
|
|
2762
|
+
or entry.destination is not None
|
|
2763
|
+
or helpers.effective_artifact_service(entry)
|
|
2764
|
+
!= helpers.effective_artifact_service(parent)
|
|
2765
|
+
):
|
|
2766
|
+
kept.append(entry)
|
|
2767
|
+
continue
|
|
2768
|
+
if child != host_path(source / "artifacts", parent).joinpath(*suffix.parts):
|
|
2769
|
+
raise ValueError("Native regrade child artifact is not at its canonical parent path")
|
|
2770
|
+
if not child.is_file() or child.is_symlink():
|
|
2771
|
+
raise ValueError(
|
|
2772
|
+
"Native regrade redundant child artifact is missing or not a regular file"
|
|
2773
|
+
)
|
|
2774
|
+
removed.append(
|
|
2775
|
+
{
|
|
2776
|
+
"declaration": entry.model_dump(mode="json"),
|
|
2777
|
+
"destination": child.relative_to(source).as_posix(),
|
|
2778
|
+
"sha256": artifact_hashes[child.relative_to(source / "artifacts").as_posix()],
|
|
2779
|
+
}
|
|
2780
|
+
)
|
|
2781
|
+
proof: dict[str, object] = {
|
|
2782
|
+
"original_task_assets_sha256": native_task_assets(task_dir),
|
|
2783
|
+
"removed_declarations": removed,
|
|
2784
|
+
}
|
|
2785
|
+
derived = task_dir
|
|
2786
|
+
if removed:
|
|
2787
|
+
proof["parent"] = _native_regrade_parent(
|
|
2788
|
+
source=source, parent=_native_object(parent.model_dump(mode="json"), "replay parent")
|
|
2789
|
+
)
|
|
2790
|
+
proof["manifest_sha256"] = artifact_hashes["manifest.json"]
|
|
2791
|
+
derived = directory / "regrade-task" / task_dir.name
|
|
2792
|
+
shutil.copytree(task_dir, derived)
|
|
2793
|
+
task.config.artifacts = kept
|
|
2794
|
+
(derived / "task.toml").write_text(task.config.model_dump_toml())
|
|
2795
|
+
proof["derived_task_assets_sha256"] = native_task_assets(derived)
|
|
2796
|
+
return derived, proof
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
def _native_regrade_parent(*, source: Path, parent: dict[str, object]) -> dict[str, object]:
|
|
2800
|
+
helpers = importlib.import_module("harbor.models.task.artifacts")
|
|
2801
|
+
host_path = importlib.import_module("harbor.trial.artifact_handler").artifact_host_path
|
|
2802
|
+
manifest = json.loads((source / "artifacts/manifest.json").read_text())
|
|
2803
|
+
if not isinstance(manifest, list):
|
|
2804
|
+
raise ValueError("Native regrade artifact manifest must be a list")
|
|
2805
|
+
entry_type = importlib.import_module(
|
|
2806
|
+
"harbor.models.trial.artifact_manifest"
|
|
2807
|
+
).ArtifactManifestEntry
|
|
2808
|
+
entries = [
|
|
2809
|
+
_native_object(
|
|
2810
|
+
entry_type.model_validate(entry).model_dump(mode="json"), "native artifact entry"
|
|
2811
|
+
)
|
|
2812
|
+
for entry in manifest
|
|
2813
|
+
]
|
|
2814
|
+
artifact_type = importlib.import_module("harbor.models.task.config").ArtifactConfig
|
|
2815
|
+
artifact = artifact_type.model_validate(parent)
|
|
2816
|
+
parent_path = host_path(source / "artifacts", artifact)
|
|
2817
|
+
matching = [
|
|
2818
|
+
entry
|
|
2819
|
+
for entry in entries
|
|
2820
|
+
if (
|
|
2821
|
+
str(entry["source"]).rstrip("/") == artifact.source.rstrip("/")
|
|
2822
|
+
and (entry.get("service") or "main") == helpers.effective_artifact_service(artifact)
|
|
2823
|
+
and entry.get("destination") == parent_path.relative_to(source).as_posix()
|
|
2824
|
+
)
|
|
2825
|
+
]
|
|
2826
|
+
if (
|
|
2827
|
+
len(matching) != 1
|
|
2828
|
+
or matching[0].get("status") != "ok"
|
|
2829
|
+
or matching[0].get("type") != "directory"
|
|
2830
|
+
or matching[0].get("exclude") != []
|
|
2831
|
+
or not parent_path.is_dir()
|
|
2832
|
+
):
|
|
2833
|
+
raise ValueError("Native regrade requires one collected, unfiltered convention parent")
|
|
2834
|
+
return matching[0]
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
def regrade_native(
|
|
2838
|
+
*,
|
|
2839
|
+
artifacts: Path,
|
|
2840
|
+
task_dir: Path,
|
|
2841
|
+
identity: dict[str, object],
|
|
2842
|
+
directory: Path,
|
|
2843
|
+
artifact_hashes: dict[str, str],
|
|
2844
|
+
) -> dict[str, object]:
|
|
2845
|
+
"""Replay only the standard native verifier over recorded immutable artifacts."""
|
|
2846
|
+
source = artifacts / "harbor/trial"
|
|
2847
|
+
source_digest = native_task_assets(source)
|
|
2848
|
+
if native_task_assets(task_dir) != identity["task_assets_sha256"]:
|
|
2849
|
+
raise ValueError("Native regrade task assets differ from the frozen task")
|
|
2850
|
+
validate_native_task(task_dir)
|
|
2851
|
+
# Regrading replays only the verifier, so the launcher version does not bind it.
|
|
2852
|
+
expected = assert_runtime(
|
|
2853
|
+
_native_object(identity["isolation"], "native regrade runtime"), launcher=False
|
|
2854
|
+
)
|
|
2855
|
+
grader = _native_object(identity["grader_image_identity"], "native regrade grader")
|
|
2856
|
+
_assert_native_image(grader, expected)
|
|
2857
|
+
toolchain = _native_object(identity["toolchain_identity"], "native regrade toolchain")
|
|
2858
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
2859
|
+
replay_task, normalization = _native_regrade_task(
|
|
2860
|
+
task_dir=task_dir, source=source, directory=directory, artifact_hashes=artifact_hashes
|
|
2861
|
+
)
|
|
2862
|
+
output = directory / "artifacts"
|
|
2863
|
+
output.mkdir(exist_ok=False)
|
|
2864
|
+
owner = uuid4().hex
|
|
2865
|
+
name = "pluginbench-" + owner
|
|
2866
|
+
config = _native_config(
|
|
2867
|
+
task_dir=replay_task,
|
|
2868
|
+
area=directory,
|
|
2869
|
+
owner=owner,
|
|
2870
|
+
harness=str(toolchain["harness"]),
|
|
2871
|
+
version=str(toolchain["harness_version"]),
|
|
2872
|
+
limits=_native_object(expected["resources"], "native regrade limits"),
|
|
2873
|
+
timeout=1,
|
|
2874
|
+
)
|
|
2875
|
+
config["source_trial"] = {"action": "regrade", "type": "local", "path": str(source.resolve())}
|
|
2876
|
+
receipt: dict[str, object] = {
|
|
2877
|
+
"source_sha256": source_digest,
|
|
2878
|
+
"task_assets_sha256": identity["task_assets_sha256"],
|
|
2879
|
+
"grader_image_identity": grader,
|
|
2880
|
+
"observer_identity": expected,
|
|
2881
|
+
"artifact_hashes": artifact_hashes,
|
|
2882
|
+
"task_normalization": normalization,
|
|
2883
|
+
"model_calls": 0,
|
|
2884
|
+
"cleanup": {"status": "incomplete"},
|
|
2885
|
+
}
|
|
2886
|
+
result: dict[str, object] = {}
|
|
2887
|
+
failure: BaseException | None = None
|
|
2888
|
+
try:
|
|
2889
|
+
worker = _native_process(
|
|
2890
|
+
area=directory,
|
|
2891
|
+
config=config,
|
|
2892
|
+
expected=expected,
|
|
2893
|
+
harness=str(toolchain["harness"]),
|
|
2894
|
+
timeout=_native_worker_budget(task_dir=task_dir),
|
|
2895
|
+
install_only=True,
|
|
2896
|
+
)
|
|
2897
|
+
if worker.get("session_id") != name + "__env":
|
|
2898
|
+
raise ValueError("Native regrade returned a foreign trial identity")
|
|
2899
|
+
result = _native_object(worker["result"], "native regrade result")
|
|
2900
|
+
if result.get("verifier_result") is not None:
|
|
2901
|
+
container = _native_container(
|
|
2902
|
+
project=name + "__verifier__trial",
|
|
2903
|
+
trial_dir=output / "harbor" / name,
|
|
2904
|
+
expected=expected,
|
|
2905
|
+
image_id=str(grader["image_id"]),
|
|
2906
|
+
)
|
|
2907
|
+
receipt["verifier"] = {
|
|
2908
|
+
"container_id": container["Id"],
|
|
2909
|
+
"image_id": container["Image"],
|
|
2910
|
+
"state": stopped_container_state(container.get("State")),
|
|
2911
|
+
}
|
|
2912
|
+
except BaseException as error:
|
|
2913
|
+
failure = error
|
|
2914
|
+
receipt["error"] = str(error)
|
|
2915
|
+
finally:
|
|
2916
|
+
receipt["cleanup"] = _native_cleanup_projects(name=name, owner=owner, expected=expected)
|
|
2917
|
+
if _native_object(receipt["cleanup"], "native regrade cleanup")["status"] != "complete":
|
|
2918
|
+
cleanup_error = ValueError("Native regrade cleanup incomplete")
|
|
2919
|
+
if failure is None:
|
|
2920
|
+
failure = cleanup_error
|
|
2921
|
+
else:
|
|
2922
|
+
failure.add_note(str(cleanup_error))
|
|
2923
|
+
if native_task_assets(source) != source_digest:
|
|
2924
|
+
failure = ValueError("Recorded native source trial changed during regrade")
|
|
2925
|
+
if native_task_assets(task_dir) != identity["task_assets_sha256"]:
|
|
2926
|
+
failure = ValueError("Frozen native task changed during regrade")
|
|
2927
|
+
if native_task_assets(replay_task) != normalization["derived_task_assets_sha256"]:
|
|
2928
|
+
failure = ValueError("Derived native replay task changed during regrade")
|
|
2929
|
+
codex.write_json(directory / "regrade-execution.json", receipt)
|
|
2930
|
+
codex.write_json(directory / "regrade-result.json", result)
|
|
2931
|
+
if failure is not None:
|
|
2932
|
+
raise failure
|
|
2933
|
+
return {
|
|
2934
|
+
"native_result": result,
|
|
2935
|
+
"verifier_result": result.get("verifier_result"),
|
|
2936
|
+
"exception_info": result.get("exception_info"),
|
|
2937
|
+
"receipt": receipt,
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
|
|
2941
|
+
if __name__ == "__main__":
|
|
2942
|
+
# Disable .env discovery before any Harbor imports and use only the parent
|
|
2943
|
+
# supplied private environment, not the operator's agent settings.
|
|
2944
|
+
os.environ["PYTHON_DOTENV_DISABLED"] = "1"
|
|
2945
|
+
payload = _object(json.loads(Path(sys.argv[1]).read_text()), "native trial config")
|
|
2946
|
+
codex.write_json(Path(sys.argv[2]), asyncio.run(run_trial(payload)))
|