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
pluginbench/code_diff.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Git diff metrics and source/test file classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import fnmatch
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_SOURCE_SUFFIXES = {
|
|
13
|
+
".c",
|
|
14
|
+
".cc",
|
|
15
|
+
".cjs",
|
|
16
|
+
".cpp",
|
|
17
|
+
".cs",
|
|
18
|
+
".cts",
|
|
19
|
+
".go",
|
|
20
|
+
".java",
|
|
21
|
+
".js",
|
|
22
|
+
".jsx",
|
|
23
|
+
".kt",
|
|
24
|
+
".kts",
|
|
25
|
+
".mjs",
|
|
26
|
+
".mts",
|
|
27
|
+
".php",
|
|
28
|
+
".py",
|
|
29
|
+
".rb",
|
|
30
|
+
".rs",
|
|
31
|
+
".swift",
|
|
32
|
+
".ts",
|
|
33
|
+
".tsx",
|
|
34
|
+
".vue",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_DEPENDENCY_FILES = {
|
|
39
|
+
"cargo.lock",
|
|
40
|
+
"cargo.toml",
|
|
41
|
+
"composer.json",
|
|
42
|
+
"composer.lock",
|
|
43
|
+
"gemfile",
|
|
44
|
+
"gemfile.lock",
|
|
45
|
+
"go.mod",
|
|
46
|
+
"go.sum",
|
|
47
|
+
"gradle.lockfile",
|
|
48
|
+
"npm-shrinkwrap.json",
|
|
49
|
+
"package-lock.json",
|
|
50
|
+
"package.json",
|
|
51
|
+
"pipfile",
|
|
52
|
+
"pipfile.lock",
|
|
53
|
+
"pnpm-lock.yaml",
|
|
54
|
+
"poetry.lock",
|
|
55
|
+
"pom.xml",
|
|
56
|
+
"pyproject.toml",
|
|
57
|
+
"setup.cfg",
|
|
58
|
+
"setup.py",
|
|
59
|
+
"yarn.lock",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class FileChange:
|
|
65
|
+
old_path: str | None
|
|
66
|
+
new_path: str | None
|
|
67
|
+
added: int
|
|
68
|
+
removed: int
|
|
69
|
+
binary: bool
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def path(self) -> str:
|
|
73
|
+
path = self.new_path or self.old_path
|
|
74
|
+
if path is None:
|
|
75
|
+
raise ValueError("diff file has no usable path")
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _marker_path(value: str) -> str | None:
|
|
80
|
+
path = value.split("\t", 1)[0]
|
|
81
|
+
if path.startswith('"'):
|
|
82
|
+
if not path.endswith('"'):
|
|
83
|
+
raise ValueError(f"cannot parse diff path: {value}")
|
|
84
|
+
# Git's C/octal escapes represent path bytes, not Unicode code points.
|
|
85
|
+
decoded = path[1:-1].encode().decode("unicode_escape")
|
|
86
|
+
path = decoded.encode("latin-1").decode("utf-8", errors="surrogateescape")
|
|
87
|
+
if not path or path == "/dev/null":
|
|
88
|
+
return None
|
|
89
|
+
return path[2:] if path.startswith(("a/", "b/")) else path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _file_changes(patch: str) -> tuple[FileChange, ...]:
|
|
93
|
+
changes: list[FileChange] = []
|
|
94
|
+
old_path: str | None = None
|
|
95
|
+
new_path: str | None = None
|
|
96
|
+
added = 0
|
|
97
|
+
removed = 0
|
|
98
|
+
binary = False
|
|
99
|
+
in_hunk = False
|
|
100
|
+
active = False
|
|
101
|
+
|
|
102
|
+
def flush() -> None:
|
|
103
|
+
nonlocal active, old_path, new_path, added, removed, binary, in_hunk
|
|
104
|
+
if active:
|
|
105
|
+
changes.append(FileChange(old_path, new_path, added, removed, binary))
|
|
106
|
+
active = False
|
|
107
|
+
old_path = None
|
|
108
|
+
new_path = None
|
|
109
|
+
added = 0
|
|
110
|
+
removed = 0
|
|
111
|
+
binary = False
|
|
112
|
+
in_hunk = False
|
|
113
|
+
|
|
114
|
+
for line in patch.splitlines():
|
|
115
|
+
if line.startswith("diff --git "):
|
|
116
|
+
flush()
|
|
117
|
+
header = re.fullmatch(
|
|
118
|
+
r'diff --git ("(?:\\.|[^"\\])*"|[ab]/.*?) ("(?:\\.|[^"\\])*"|[ab]/.*)', line
|
|
119
|
+
)
|
|
120
|
+
if header is None:
|
|
121
|
+
raise ValueError(f"invalid diff header: {line}")
|
|
122
|
+
old_path = _marker_path(header[1])
|
|
123
|
+
new_path = _marker_path(header[2])
|
|
124
|
+
active = True
|
|
125
|
+
elif active and not in_hunk and line.startswith("--- "):
|
|
126
|
+
old_path = _marker_path(line[4:])
|
|
127
|
+
elif active and not in_hunk and line.startswith("+++ "):
|
|
128
|
+
new_path = _marker_path(line[4:])
|
|
129
|
+
elif active and line.startswith("@@"):
|
|
130
|
+
in_hunk = True
|
|
131
|
+
elif active and (line == "GIT binary patch" or line.startswith("Binary files ")):
|
|
132
|
+
binary = True
|
|
133
|
+
in_hunk = False
|
|
134
|
+
elif active and in_hunk and line.startswith("+"):
|
|
135
|
+
added += 1
|
|
136
|
+
elif active and in_hunk and line.startswith("-"):
|
|
137
|
+
removed += 1
|
|
138
|
+
flush()
|
|
139
|
+
if patch.strip() and not changes:
|
|
140
|
+
raise ValueError("expected a Git diff with 'diff --git' headers; unsupported patch format")
|
|
141
|
+
return tuple(changes)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
TEST_DIRECTORY_NAMES = frozenset(
|
|
145
|
+
{"test", "tests", "testing", "spec", "specs", "__tests", "__tests__"}
|
|
146
|
+
)
|
|
147
|
+
TEST_FILENAME_PATTERNS = ("test_*", "*.test.*", "*.spec.*", "*_test.py", "*_test.go")
|
|
148
|
+
TEST_PATH_PATTERNS = tuple(
|
|
149
|
+
pattern
|
|
150
|
+
for directory in sorted(TEST_DIRECTORY_NAMES)
|
|
151
|
+
for pattern in (f"{directory}/**", f"**/{directory}/**")
|
|
152
|
+
) + tuple(
|
|
153
|
+
pattern for filename in TEST_FILENAME_PATTERNS for pattern in (filename, f"**/{filename}")
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_test_path(path: str) -> bool:
|
|
158
|
+
parts = Path(path).parts
|
|
159
|
+
name = Path(path).name.lower()
|
|
160
|
+
return any(part.lower() in TEST_DIRECTORY_NAMES for part in parts[:-1]) or any(
|
|
161
|
+
fnmatch.fnmatchcase(name, pattern) for pattern in TEST_FILENAME_PATTERNS
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_source_path(path: str) -> bool:
|
|
166
|
+
return not _is_test_path(path) and Path(path).suffix.lower() in _SOURCE_SUFFIXES
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _is_dependency_manifest(path: str) -> bool:
|
|
170
|
+
name = Path(path).name.lower()
|
|
171
|
+
return (
|
|
172
|
+
name in _DEPENDENCY_FILES
|
|
173
|
+
or (name.startswith("requirements") and name.endswith(".txt"))
|
|
174
|
+
or name.startswith("build.gradle")
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def analyze_patch(patch: str) -> dict[str, Any]:
|
|
179
|
+
"""Return transparent line and path metrics for one saved Git patch."""
|
|
180
|
+
changes = _file_changes(patch)
|
|
181
|
+
source = [change for change in changes if _is_source_path(change.path)]
|
|
182
|
+
tests = [change for change in changes if _is_test_path(change.path)]
|
|
183
|
+
manifests = sorted(change.path for change in changes if _is_dependency_manifest(change.path))
|
|
184
|
+
lines_added = sum(change.added for change in changes)
|
|
185
|
+
lines_removed = sum(change.removed for change in changes)
|
|
186
|
+
source_added = sum(change.added for change in source)
|
|
187
|
+
source_removed = sum(change.removed for change in source)
|
|
188
|
+
test_added = sum(change.added for change in tests)
|
|
189
|
+
test_removed = sum(change.removed for change in tests)
|
|
190
|
+
return {
|
|
191
|
+
"changed_paths": sorted(change.path for change in changes),
|
|
192
|
+
"files_changed": len(changes),
|
|
193
|
+
"source_files_changed": len(source),
|
|
194
|
+
"test_files_changed": len(tests),
|
|
195
|
+
"lines_added": lines_added,
|
|
196
|
+
"lines_removed": lines_removed,
|
|
197
|
+
"churn": lines_added + lines_removed,
|
|
198
|
+
"source_lines_added": source_added,
|
|
199
|
+
"source_lines_removed": source_removed,
|
|
200
|
+
"source_churn": source_added + source_removed,
|
|
201
|
+
"test_lines_added": test_added,
|
|
202
|
+
"test_lines_removed": test_removed,
|
|
203
|
+
"test_churn": test_added + test_removed,
|
|
204
|
+
"binary_patch": any(change.binary for change in changes),
|
|
205
|
+
"empty_patch": not patch.strip(),
|
|
206
|
+
"dependency_manifest_changes": manifests,
|
|
207
|
+
}
|
pluginbench/codex.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Native CLI execution and evidence for isolated reviews and Harbor submissions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import tarfile
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pluginbench import isolation
|
|
16
|
+
from pluginbench.codex_events import admit_version as admit_version
|
|
17
|
+
from pluginbench.codex_events import observed_cli_version as observed_cli_version
|
|
18
|
+
from pluginbench.codex_events import parse_events as parse_events
|
|
19
|
+
from pluginbench.processes import terminate_process_group
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
GUIDANCE_NAMES = {
|
|
23
|
+
"AGENTS.md",
|
|
24
|
+
"AGENTS.override.md",
|
|
25
|
+
"CLAUDE.md",
|
|
26
|
+
".agents",
|
|
27
|
+
".claude",
|
|
28
|
+
".codex",
|
|
29
|
+
".claude-plugin",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reject_guidance(
|
|
34
|
+
workspace: Path, *, include_ancestors: bool = True, reject_git: bool = False
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Reject undeclared instruction discovery surfaces in task source."""
|
|
37
|
+
root = workspace.resolve()
|
|
38
|
+
if not root.is_dir():
|
|
39
|
+
raise ValueError(f"workspace is not a directory: {workspace}")
|
|
40
|
+
contaminants = []
|
|
41
|
+
for path in root.rglob("*"):
|
|
42
|
+
relative = path.relative_to(root)
|
|
43
|
+
if ".git" in relative.parts and not reject_git:
|
|
44
|
+
continue
|
|
45
|
+
if path.is_symlink():
|
|
46
|
+
contaminants.append(str(path))
|
|
47
|
+
elif path.name in GUIDANCE_NAMES or (reject_git and path.name == ".git"):
|
|
48
|
+
contaminants.append(str(path))
|
|
49
|
+
if include_ancestors:
|
|
50
|
+
contaminants.extend(
|
|
51
|
+
str(parent / name)
|
|
52
|
+
for parent in root.parents
|
|
53
|
+
for name in GUIDANCE_NAMES
|
|
54
|
+
if (parent / name).exists()
|
|
55
|
+
)
|
|
56
|
+
if contaminants:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"custom guidance or unsupported fixture paths: " + ", ".join(sorted(contaminants))
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def preflight() -> dict[str, Any]:
|
|
63
|
+
"""Check native runtime capabilities locally without invoking a model."""
|
|
64
|
+
outputs = {}
|
|
65
|
+
for key, arguments in (("version", ["--version"]), ("help", ["exec", "--help"])):
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["codex", *arguments], capture_output=True, text=True, timeout=15, check=False
|
|
68
|
+
)
|
|
69
|
+
if result.returncode:
|
|
70
|
+
raise ValueError(f"codex {key} check failed (exit {result.returncode})")
|
|
71
|
+
outputs[key] = result.stdout
|
|
72
|
+
required = (
|
|
73
|
+
"--ignore-user-config",
|
|
74
|
+
"--ignore-rules",
|
|
75
|
+
"--json",
|
|
76
|
+
"--model",
|
|
77
|
+
"--config",
|
|
78
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
79
|
+
"--skip-git-repo-check",
|
|
80
|
+
"--cd",
|
|
81
|
+
)
|
|
82
|
+
missing = [flag for flag in required if flag not in outputs["help"]]
|
|
83
|
+
if missing:
|
|
84
|
+
raise ValueError("native codex lacks required isolation flags: " + ", ".join(missing))
|
|
85
|
+
return {
|
|
86
|
+
"harness": "codex-cli",
|
|
87
|
+
"harness_version": outputs["version"].strip(),
|
|
88
|
+
"help": outputs["help"],
|
|
89
|
+
"required_flags": list(required),
|
|
90
|
+
"paid_calls": 0,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_json(path: Path, value: object) -> None:
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
temporary = path.with_suffix(path.suffix + ".new")
|
|
97
|
+
temporary.write_text(json.dumps(value, indent=2) + "\n")
|
|
98
|
+
temporary.replace(path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def credential_values(auth_file: Path) -> tuple[str, ...]:
|
|
102
|
+
"""Collect credential scalars for redaction without exposing them in receipts."""
|
|
103
|
+
values: list[str] = []
|
|
104
|
+
|
|
105
|
+
def collect(value: Any) -> None:
|
|
106
|
+
if isinstance(value, dict):
|
|
107
|
+
for child in value.values():
|
|
108
|
+
collect(child)
|
|
109
|
+
elif isinstance(value, list):
|
|
110
|
+
for child in value:
|
|
111
|
+
collect(child)
|
|
112
|
+
elif isinstance(value, str) and len(value) > 20:
|
|
113
|
+
values.append(value)
|
|
114
|
+
|
|
115
|
+
collect(json.loads(auth_file.read_text()))
|
|
116
|
+
return tuple(values)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_artifacts(directory: Path) -> None:
|
|
120
|
+
"""Accept only ordinary directories and files before reading native evidence."""
|
|
121
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
122
|
+
raise ValueError("native artifact root must be an ordinary directory")
|
|
123
|
+
|
|
124
|
+
def failed(error: OSError) -> None:
|
|
125
|
+
raise error
|
|
126
|
+
|
|
127
|
+
for root, names, files in os.walk(directory, followlinks=False, onerror=failed):
|
|
128
|
+
for name in [*names, *files]:
|
|
129
|
+
path = Path(root) / name
|
|
130
|
+
mode = path.lstat().st_mode
|
|
131
|
+
if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
132
|
+
raise ValueError(f"unsupported native artifact: {path.relative_to(directory)}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def copy_artifacts(source: Path, target: Path) -> str | None:
|
|
136
|
+
"""Preserve unsupported evidence as an inert archive, without following links."""
|
|
137
|
+
try:
|
|
138
|
+
validate_artifacts(source)
|
|
139
|
+
except (OSError, ValueError) as error:
|
|
140
|
+
target.mkdir(parents=True)
|
|
141
|
+
with tarfile.open(target / "unsupported-artifacts.tar", "w", dereference=False) as archive:
|
|
142
|
+
archive.add(source, arcname="artifacts")
|
|
143
|
+
write_json(target / "unsupported-artifacts.json", {"error": str(error)})
|
|
144
|
+
return str(error)
|
|
145
|
+
shutil.copytree(source, target, symlinks=True)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def scrub_artifacts(directory: Path, secrets: tuple[str, ...]) -> None:
|
|
150
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
151
|
+
return
|
|
152
|
+
for root, names, files in os.walk(directory, followlinks=False):
|
|
153
|
+
names[:] = [name for name in names if not (Path(root) / name).is_symlink()]
|
|
154
|
+
for name in files:
|
|
155
|
+
path = Path(root) / name
|
|
156
|
+
if not stat.S_ISREG(path.lstat().st_mode):
|
|
157
|
+
continue
|
|
158
|
+
original = path.read_bytes()
|
|
159
|
+
content = original
|
|
160
|
+
for secret in secrets:
|
|
161
|
+
content = content.replace(secret.encode(), b"<redacted-credential>")
|
|
162
|
+
if content != original:
|
|
163
|
+
path.write_bytes(content)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _native_traces(private: Path, artifacts: Path, secrets: tuple[str, ...]) -> list[str]:
|
|
167
|
+
sessions = private / "codex/sessions"
|
|
168
|
+
for path in (private, private / "codex", sessions):
|
|
169
|
+
if path.is_symlink():
|
|
170
|
+
copy_artifacts(path, artifacts / "unsupported-native-traces")
|
|
171
|
+
raise ValueError("unsupported native session ancestor link")
|
|
172
|
+
if path.exists() and not path.is_dir():
|
|
173
|
+
raise ValueError("native session ancestors must be ordinary directories")
|
|
174
|
+
if not sessions.exists():
|
|
175
|
+
return []
|
|
176
|
+
try:
|
|
177
|
+
validate_artifacts(sessions)
|
|
178
|
+
except (OSError, ValueError):
|
|
179
|
+
scrub_artifacts(sessions, secrets)
|
|
180
|
+
copy_artifacts(sessions, artifacts / "unsupported-native-traces")
|
|
181
|
+
raise
|
|
182
|
+
return [path.read_text() for path in sorted(sessions.rglob("*.jsonl"))]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def runtime_environment(
|
|
186
|
+
private: Path, host: str, *, auth_file: Path | None = None
|
|
187
|
+
) -> tuple[dict[str, str], tuple[str, ...]]:
|
|
188
|
+
if host == "claude":
|
|
189
|
+
from pluginbench import claude
|
|
190
|
+
|
|
191
|
+
return claude.runtime_environment(private, auth_file=auth_file)
|
|
192
|
+
private.mkdir(mode=0o700)
|
|
193
|
+
home = private / "home"
|
|
194
|
+
home.mkdir(mode=0o700)
|
|
195
|
+
temporary = private / "tmp"
|
|
196
|
+
temporary.mkdir(mode=0o700)
|
|
197
|
+
env = {
|
|
198
|
+
key: value
|
|
199
|
+
for key, value in os.environ.items()
|
|
200
|
+
if key
|
|
201
|
+
in {
|
|
202
|
+
"PATH",
|
|
203
|
+
"LANG",
|
|
204
|
+
"LC_ALL",
|
|
205
|
+
"SSL_CERT_FILE",
|
|
206
|
+
"SSL_CERT_DIR",
|
|
207
|
+
"NODE_EXTRA_CA_CERTS",
|
|
208
|
+
"HTTP_PROXY",
|
|
209
|
+
"HTTPS_PROXY",
|
|
210
|
+
"NO_PROXY",
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
env.update(
|
|
214
|
+
HOME=str(home), TMPDIR=str(temporary), XDG_CONFIG_HOME=str(home / ".config"), NO_COLOR="1"
|
|
215
|
+
)
|
|
216
|
+
secrets: list[str] = []
|
|
217
|
+
if host == "codex":
|
|
218
|
+
config = private / "codex"
|
|
219
|
+
config.mkdir(mode=0o700)
|
|
220
|
+
auth = auth_file if auth_file is not None else Path.home() / ".codex/auth.json"
|
|
221
|
+
shutil.copy2(auth, config / "auth.json")
|
|
222
|
+
(config / "auth.json").chmod(0o600)
|
|
223
|
+
|
|
224
|
+
secrets.extend(credential_values(auth))
|
|
225
|
+
env["CODEX_HOME"] = str(config)
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError(f"unsupported native host: {host}")
|
|
228
|
+
return env, tuple(secrets)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def command(
|
|
232
|
+
*,
|
|
233
|
+
host: str,
|
|
234
|
+
model: str,
|
|
235
|
+
workspace: Path,
|
|
236
|
+
effort: str = "medium",
|
|
237
|
+
) -> list[str]:
|
|
238
|
+
if host == "claude":
|
|
239
|
+
from pluginbench import claude
|
|
240
|
+
|
|
241
|
+
return claude.command(model=model, effort=effort)
|
|
242
|
+
if host == "codex":
|
|
243
|
+
return [
|
|
244
|
+
"codex",
|
|
245
|
+
"exec",
|
|
246
|
+
"--ignore-user-config",
|
|
247
|
+
"--ignore-rules",
|
|
248
|
+
"--json",
|
|
249
|
+
"-m",
|
|
250
|
+
model,
|
|
251
|
+
"-c",
|
|
252
|
+
f"model_reasoning_effort={json.dumps(effort)}",
|
|
253
|
+
"-c",
|
|
254
|
+
'web_search="disabled"',
|
|
255
|
+
"-c",
|
|
256
|
+
"features.multi_agent=false",
|
|
257
|
+
"--enable",
|
|
258
|
+
"code_mode",
|
|
259
|
+
"--enable",
|
|
260
|
+
"code_mode_only",
|
|
261
|
+
# The mandatory outer OS wrapper owns read-only scope. Nested Seatbelt fails.
|
|
262
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
263
|
+
"--skip-git-repo-check",
|
|
264
|
+
"-C",
|
|
265
|
+
str(workspace),
|
|
266
|
+
"-",
|
|
267
|
+
]
|
|
268
|
+
raise ValueError(f"unsupported native host: {host}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def validated_claude_launch(execution: dict[str, Any], model: str, effort: str) -> bool:
|
|
272
|
+
"""Bind a requested effort to an isolated launch without claiming effective effort."""
|
|
273
|
+
launch = execution.get("launch")
|
|
274
|
+
if not isinstance(launch, dict) or execution.get("actual_primary_models") != [model]:
|
|
275
|
+
return False
|
|
276
|
+
argv = launch.get("argv")
|
|
277
|
+
return (
|
|
278
|
+
bool(
|
|
279
|
+
launch.get("harness") == "claude-cli"
|
|
280
|
+
and launch.get("requested_model") == model
|
|
281
|
+
and launch.get("requested_effort") == effort
|
|
282
|
+
and launch.get("harness_version")
|
|
283
|
+
and isinstance(argv, list)
|
|
284
|
+
and ["--model", model] == argv[argv.index("--model") : argv.index("--model") + 2]
|
|
285
|
+
and ["--effort", effort] == argv[argv.index("--effort") : argv.index("--effort") + 2]
|
|
286
|
+
and "--safe-mode" in argv
|
|
287
|
+
and execution.get("runtime_effort_evidence") in ([], [effort])
|
|
288
|
+
)
|
|
289
|
+
if isinstance(argv, list) and "--model" in argv and "--effort" in argv
|
|
290
|
+
else False
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def invoke(
|
|
295
|
+
*,
|
|
296
|
+
area: Path,
|
|
297
|
+
entry: dict[str, Any],
|
|
298
|
+
protocol: dict[str, Any],
|
|
299
|
+
prompt: str,
|
|
300
|
+
timeout: int,
|
|
301
|
+
output_schema: Path | None = None,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
"""Invoke a local reviewer with an OS-enforced read-only source packet."""
|
|
304
|
+
artifacts = area / "artifacts"
|
|
305
|
+
artifacts.mkdir(exist_ok=True)
|
|
306
|
+
if any((artifacts / name).exists() for name in ("invocation.json", "execution.json")):
|
|
307
|
+
raise ValueError("native invocation artifacts already exist; use a fresh trial area")
|
|
308
|
+
private = area / "private"
|
|
309
|
+
effort = protocol.get("reasoning", "medium")
|
|
310
|
+
if not isinstance(effort, str) or not effort:
|
|
311
|
+
raise ValueError("reasoning must be a nonempty effort string")
|
|
312
|
+
argv = command(
|
|
313
|
+
host=entry["host"],
|
|
314
|
+
model=protocol["models"][entry["host"]],
|
|
315
|
+
workspace=area / "workspace",
|
|
316
|
+
effort=effort,
|
|
317
|
+
)
|
|
318
|
+
if output_schema is not None:
|
|
319
|
+
if entry["host"] == "claude":
|
|
320
|
+
argv.extend(["--json-schema", json.dumps(json.loads(output_schema.read_text()))])
|
|
321
|
+
else:
|
|
322
|
+
argv[-1:-1] = ["--output-schema", str(output_schema)]
|
|
323
|
+
launch = {
|
|
324
|
+
"harness": entry["host"] + "-cli",
|
|
325
|
+
"requested_model": protocol["models"][entry["host"]],
|
|
326
|
+
"requested_effort": effort,
|
|
327
|
+
"argv": argv,
|
|
328
|
+
}
|
|
329
|
+
if entry["host"] == "claude":
|
|
330
|
+
from pluginbench import claude
|
|
331
|
+
|
|
332
|
+
launch["harness_version"] = claude.preflight()["harness_version"]
|
|
333
|
+
write_json(
|
|
334
|
+
artifacts / "invocation.json",
|
|
335
|
+
{**launch, "timeout_seconds": timeout},
|
|
336
|
+
)
|
|
337
|
+
(artifacts / "prompt.txt").write_text(prompt)
|
|
338
|
+
secrets: tuple[str, ...] = ()
|
|
339
|
+
started = time.monotonic()
|
|
340
|
+
timed_out = False
|
|
341
|
+
proc = None
|
|
342
|
+
traces = []
|
|
343
|
+
failure: BaseException | None = None
|
|
344
|
+
try:
|
|
345
|
+
auth_file = protocol.get("auth_file")
|
|
346
|
+
env, secrets = runtime_environment(
|
|
347
|
+
private,
|
|
348
|
+
entry["host"],
|
|
349
|
+
**({"auth_file": Path(auth_file).expanduser()} if auth_file is not None else {}),
|
|
350
|
+
)
|
|
351
|
+
argv, filesystem = isolation.command(
|
|
352
|
+
argv,
|
|
353
|
+
writable=[private],
|
|
354
|
+
readable=[area / "workspace", *([output_schema] if output_schema else [])],
|
|
355
|
+
expected=protocol.get("isolation"),
|
|
356
|
+
)
|
|
357
|
+
write_json(
|
|
358
|
+
artifacts / "runtime-isolation.json",
|
|
359
|
+
{
|
|
360
|
+
"environment_keys": sorted(env),
|
|
361
|
+
"private_startup_files": sorted(
|
|
362
|
+
str(p.relative_to(private)) for p in private.rglob("*") if p.is_file()
|
|
363
|
+
),
|
|
364
|
+
"fresh_home": not any((private / "home").iterdir())
|
|
365
|
+
if (private / "home").is_dir()
|
|
366
|
+
else None,
|
|
367
|
+
"configuration_source": "private auth only; user/project setting sources disabled",
|
|
368
|
+
"native_builtin_instructions": "retained",
|
|
369
|
+
"filesystem": filesystem,
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
with (
|
|
373
|
+
(artifacts / "stdout.jsonl").open("w") as stdout,
|
|
374
|
+
(artifacts / "stderr.log").open("w") as stderr,
|
|
375
|
+
):
|
|
376
|
+
proc = subprocess.Popen(
|
|
377
|
+
argv,
|
|
378
|
+
cwd=area / "workspace",
|
|
379
|
+
env=env,
|
|
380
|
+
stdin=subprocess.PIPE,
|
|
381
|
+
stdout=stdout,
|
|
382
|
+
stderr=stderr,
|
|
383
|
+
text=True,
|
|
384
|
+
start_new_session=True,
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
proc.communicate(prompt, timeout=timeout)
|
|
388
|
+
except subprocess.TimeoutExpired:
|
|
389
|
+
timed_out = True
|
|
390
|
+
except BaseException as error:
|
|
391
|
+
failure = error
|
|
392
|
+
raise
|
|
393
|
+
finally:
|
|
394
|
+
cleanup_error: OSError | subprocess.TimeoutExpired | None = None
|
|
395
|
+
trace_error: OSError | ValueError | None = None
|
|
396
|
+
try:
|
|
397
|
+
if proc is not None:
|
|
398
|
+
terminate_process_group(proc)
|
|
399
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
400
|
+
cleanup_error = error
|
|
401
|
+
if failure is not None:
|
|
402
|
+
failure.add_note(f"Process-group cleanup failed: {error}")
|
|
403
|
+
try:
|
|
404
|
+
traces = _native_traces(private, artifacts, secrets)
|
|
405
|
+
for index, trace in enumerate(traces):
|
|
406
|
+
(artifacts / f"host-trace-{index}.jsonl").write_text(trace)
|
|
407
|
+
except (OSError, ValueError) as error:
|
|
408
|
+
trace_error = error
|
|
409
|
+
if failure is not None:
|
|
410
|
+
failure.add_note(f"Native session evidence rejected: {error}")
|
|
411
|
+
finally:
|
|
412
|
+
try:
|
|
413
|
+
if private.is_symlink():
|
|
414
|
+
private.unlink()
|
|
415
|
+
elif private.exists():
|
|
416
|
+
shutil.rmtree(private)
|
|
417
|
+
finally:
|
|
418
|
+
try:
|
|
419
|
+
scrub_artifacts(artifacts, secrets)
|
|
420
|
+
finally:
|
|
421
|
+
write_json(
|
|
422
|
+
artifacts / "execution.json",
|
|
423
|
+
{
|
|
424
|
+
"exit_code": proc.returncode if proc is not None else None,
|
|
425
|
+
"timed_out": timed_out,
|
|
426
|
+
"elapsed_seconds": time.monotonic() - started,
|
|
427
|
+
"private_runtime_removed": not private.exists(),
|
|
428
|
+
"cleanup_error": str(cleanup_error)
|
|
429
|
+
if cleanup_error is not None
|
|
430
|
+
else None,
|
|
431
|
+
"trace_error": str(trace_error) if trace_error is not None else None,
|
|
432
|
+
"successful_native_completion": False
|
|
433
|
+
if trace_error is not None
|
|
434
|
+
else not timed_out and proc is not None and proc.returncode == 0,
|
|
435
|
+
},
|
|
436
|
+
)
|
|
437
|
+
if cleanup_error is not None and failure is None:
|
|
438
|
+
raise cleanup_error
|
|
439
|
+
if trace_error is not None and failure is None:
|
|
440
|
+
raise trace_error
|
|
441
|
+
validate_artifacts(artifacts)
|
|
442
|
+
traces = [p.read_text() for p in sorted(artifacts.glob("host-trace-*.jsonl"))]
|
|
443
|
+
parsed = parse_events(
|
|
444
|
+
(artifacts / "stdout.jsonl").read_text(), traces, entry["host"], requested_effort=effort
|
|
445
|
+
)
|
|
446
|
+
if timed_out or proc is None or proc.returncode != 0:
|
|
447
|
+
parsed["successful_native_completion"] = False
|
|
448
|
+
parsed["launch"] = launch
|
|
449
|
+
parsed["launch_harness_version"] = launch.get("harness_version")
|
|
450
|
+
write_json(artifacts / "host-evidence.json", parsed)
|
|
451
|
+
return {**json.loads((artifacts / "execution.json").read_text()), **parsed}
|