f3363593f4
* llama-eval : add sandboxed execution for code suites Adds a lightweight replacement for the Docker containers usually used to run generated code, so a benchmark can be executed without a daemon, an image or root. A cached venv is provisioned per suite and reused; candidate programs run in a throwaway working directory as their own process group, under the strongest isolation the host offers: bwrap read-only root, tmpfs /tmp and $HOME, no network unshare no network, via --map-current-user rlimit cpu/memory/file-size/process caps only Candidate code must not run as uid 0, which is why "unshare -r" is a last-resort tier that warns: root bypasses file permission bits, so a test asserting that a write to a chmod 0444 file fails would instead see it succeed. Two details worth noting for the bwrap tier. Only paths hidden by the $HOME tmpfs are bound back, because bwrap cannot mount onto a symlink and version-manager alias directories usually are one. And the venv's base interpreter tree is resolved by walking the symlink chain, since a version-managed interpreter is commonly reached through an unversioned alias that startup resolves through. Suites can declare provisioning steps, which run once at build time while the network is still up, so test time never needs it. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com> * llama-eval : add HumanEval and ClassEval code suites Adds two execution-graded suites alongside the existing text-graded ones. The model's reply is parsed for a python code block, combined with the task's own unit tests, and run in the sandbox. --dataset humaneval 164 function-level problems, scored pass@k --dataset classeval 100 class-level tasks, scored at class level (feeding pass@k) and method level, matching upstream pass@k reuses the existing chunk mechanism: --n_cases at a multiple of the dataset size gives that many samples per problem, aggregated with the unbiased estimator from the Codex paper. Program construction and result interpretation are dataset-owned hooks, because the two suites differ: one HumanEval problem is a function judged by a single assert, whereas one ClassEval task is a class judged by many independent test methods, and reducing that to pass/fail would throw away partial credit. Suites also declare their own package set, provisioning steps, interpreter and timeout. The package set is derived from the dataset's own imports rather than a requirements file. HumanEval turns out to need no third-party packages at all; ClassEval needs thirteen, two of which must be declared because no import mentions them -- lxml is selected by string in ClassEval_44, and scipy is a gensim requirement. Notes on ClassEval specifically: * gensim publishes no wheel past cp313, so 3.13 is preferred; on 3.14+ the two pure-Python APIs it uses are provided by a small shim instead. * The three nltk corpora it needs are pre-seeded at venv build time. These are the modern names -- the dataset asks for the since-renamed punkt and averaged_perceptron_tagger. * It defaults to a patched fork, ilintar/ClassEval, because three upstream tests cannot be passed by anything: a hard-coded 2024 date checked against datetime.now(), an assertion on the dataset author's own hostname, and an exact float comparison. Reference solutions go from 96/100 to 100/100 classes and 2182/2196 to 2196/2196 test methods. No skeleton is touched, so what the model is asked to write is unchanged; --dataset-source selects the original. --exec-seed (default 0) seeds the RNG before each test, so suites whose tests draw random inputs score reproducibly. ClassEval_58 fails its own reference solution about 30% of the time without it. The simulator gains both suites, so the harness can be exercised without a model. Assisted-by: Claude Opus 4.8 <noreply@anthropic.com> * llama-eval : fix silent isolation downgrade, generalise the sandbox runner The isolation probe ran the interpreter without binding it back, so whenever this tool is itself started from a venv under $HOME -- the usual case, since it needs `datasets` -- bwrap's tmpfs hid the interpreter, the probe failed, and the sandbox quietly fell back to a tier that does not hide $HOME at all. Nothing in the output looked wrong when that happened, and the existing suites have been running that way. The probe now uses the same interpreter and the same binds the suite will actually get. Two additions the agentic suite needs, neither of which changes the existing suites: * run_argv() runs an arbitrary command in a caller-owned directory that survives the call. run() is now a thin wrapper over it, so HumanEval and ClassEval take exactly the same path as before. A multi-step agent needs its edits to persist across many invocations, and a suite for a language other than Python needs to drive a linter and a test runner rather than a single source string. * RLIMIT_AS is now per-sandbox, defaulting to what it was. It caps reserved address space rather than resident memory, which is the wrong instrument for a runtime that reserves a large region and commits little of it: node's TypeScript type-stripping goes through WebAssembly, reserves gigabytes per instance, and aborts at 4 GiB with an out-of-memory error that reads like a fault in the code under test. HumanEval stays at 164/164 and ClassEval at 100/100. Assisted-by: Claude Opus 5 <noreply@anthropic.com> * llama-eval : add SACB, a multi-turn agentic coding suite `--dataset agentic` is a different shape from the other code suites. Rather than answering a single prompt, the model is given a small but real repository, a narrow tool set and a bug report, drives its own multi-turn conversation, and is graded by running a test suite it was never allowed to see. The corpus is SACB (Simple Agent Coding Benchmark), 60 tasks over three hand-written repositories in Python and TypeScript, hosted at ilintar/SACB. Grading needs no container: a cached venv gives ruff and mypy for Python, and node's own test runner plus tsc for TypeScript, so the whole dependency set is four packages. Three constraints are deliberate: * No shell, and no way to run the tests. The only feedback channel is `lint`. A syntax or type error can be driven out mechanically; a logic error has to be reasoned about. A harness that lets an agent iterate against the test suite measures something closer to search than to understanding. * Two edit tools, one line-addressed and one content-addressed. Which one a model reaches for, and whether it keeps line numbers straight after its own earlier edit, is itself a signal. * The tests never touch the working tree. They are held outside it and copied into a throwaway copy only once the agent has stopped, so they can be neither read nor edited. Every tool path is resolved and confinement-checked against the repository root, so a symlink planted inside the tree cannot reach out of it. A task counts as resolved when every test that was failing passes and nothing that already passed broke. Both sets are derived by running the tests against the defective tree and against the reference fix, never written by hand, so a task whose defect no test exercises cannot enter the corpus. Many tasks carry several independent defects, so the fraction of target tests passed is reported alongside, separating "fixed two of the three faults" from "changed nothing". An episode that arrives with no tool call and no `finish` is nudged rather than ended. Models routinely narrate their analysis mid-task, and treating that as completion ends the episode with no edits and scores a reasoning failure that never happened; about one episode in four exits that way. Nothing here is installed unless this suite is selected, and then only for the language being run: agentic_eval is imported lazily and each toolchain is keyed per language. Assisted-by: Claude Opus 5 <noreply@anthropic.com> * llama-eval : make the agentic hooks typed rather than discovered `ty` flagged nine unresolved attributes, of which the seven in main() predate the agentic suite: `EvalState.dataset` is Optional and was dereferenced without narrowing. * `loaded_dataset` turns the Optional into a value in one place, raising rather than handing back None, so callers do not each have to narrow it. * `is_agentic`, `prepare()` and `run()` are declared on BaseDataset. They were reached through getattr with a default at three call sites, which no checker can see through; `run()` now raises a clear error naming the suite instead of failing as a missing attribute. * The sandbox setup is one branch on `is_agentic` rather than two tests of `grader_type`, which also drops a duplicated condition. No behaviour change: HumanEval stays at 164/164, and `ty check` on examples/llama-eval is clean. Assisted-by: Claude Opus 5 <noreply@anthropic.com> * feat: API key * fix reasoning and response length for agentic runs, add option to save entire transcripts * llama-eval : fix ty unresolved-attribute on loaded_dataset.ae Assisted-by: AI coding agent * llama-eval : trim comments and remove non-ASCII from README Assisted-by: AI coding agent
489 lines
18 KiB
Python
489 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Lightweight sandboxed execution for code-generation evals (HumanEval, ClassEval).
|
|
|
|
Replaces the usual Docker-per-suite setup with a cached venv plus whatever
|
|
process isolation the host can give us without root:
|
|
|
|
tier 1 bubblewrap read-only root, tmpfs /tmp and $HOME, no network
|
|
tier 2 unshare -rn no network (home still visible)
|
|
tier 3 rlimits only last resort, still capped on cpu/memory/procs/files
|
|
|
|
Every tier also applies rlimits and runs the candidate program in a throwaway
|
|
working directory as its own process group, so a runaway solution is killed
|
|
along with anything it spawned.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import resource
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import venv
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Sequence
|
|
|
|
DEFAULT_CACHE_ROOT = Path(
|
|
os.environ.get("LLAMA_EVAL_CACHE", Path.home() / ".cache" / "llama-eval")
|
|
)
|
|
|
|
LIMIT_AS_BYTES = 4 * 1024 * 1024 * 1024
|
|
LIMIT_CPU_SECONDS_SLACK = 5
|
|
LIMIT_NPROC = 256
|
|
LIMIT_FSIZE_BYTES = 64 * 1024 * 1024
|
|
|
|
|
|
@dataclass
|
|
class ExecResult:
|
|
passed: bool
|
|
status: str
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
returncode: Optional[int] = None
|
|
duration: float = 0.0
|
|
|
|
def summary(self, max_chars: int = 400) -> str:
|
|
"""Short human-readable reason, for grader logs."""
|
|
if self.passed:
|
|
return "ok"
|
|
if self.status == "timeout":
|
|
return "timeout"
|
|
tail = (self.stderr or self.stdout).strip()
|
|
if not tail:
|
|
return f"{self.status} (rc={self.returncode})"
|
|
lines = [ln for ln in tail.splitlines() if ln.strip()]
|
|
reason = lines[-1] if lines else tail
|
|
return reason[:max_chars]
|
|
|
|
|
|
def resolve_python(spec: Optional[str]) -> Optional[str]:
|
|
"""Turn '3.13' or a path into a usable interpreter, or None if unavailable.
|
|
|
|
Suites can need an older interpreter than the one running this tool, because
|
|
a dependency has no wheel (or no working source build) for the newest
|
|
CPython. Accepts an explicit path, a bare version, or an executable name.
|
|
"""
|
|
if not spec:
|
|
return None
|
|
|
|
candidate = Path(spec).expanduser()
|
|
if candidate.exists() and candidate.is_file():
|
|
return str(candidate)
|
|
|
|
uv = shutil.which("uv")
|
|
if uv and re.fullmatch(r"\d+\.\d+(\.\d+)?", spec):
|
|
try:
|
|
r = subprocess.run([uv, "python", "find", spec],
|
|
capture_output=True, text=True, timeout=120)
|
|
path = r.stdout.strip()
|
|
if r.returncode == 0 and path and Path(path).exists():
|
|
return path
|
|
except Exception:
|
|
pass
|
|
|
|
return shutil.which(spec) or shutil.which(f"python{spec}")
|
|
|
|
|
|
def _detect_isolation(probe_python: Optional[str] = None,
|
|
ro_binds: Sequence[Path] = ()) -> str:
|
|
"""Pick the strongest isolation tier this host supports, once.
|
|
|
|
The probe must run the *same* interpreter the suite will, with the same
|
|
binds. Probing a bare sys.executable silently downgrades the tier whenever
|
|
this tool is itself run from a venv under $HOME -- which is the usual case,
|
|
since it needs `datasets`. bwrap tmpfs-mounts the home directory, the
|
|
interpreter disappears, the probe fails, and the sandbox falls back to a
|
|
tier that does not hide $HOME at all. Nothing about the results looks wrong
|
|
when that happens, so it has to be got right here.
|
|
"""
|
|
py = probe_python or sys.executable
|
|
probe = "import sys; sys.exit(0)"
|
|
|
|
if shutil.which("bwrap"):
|
|
try:
|
|
r = subprocess.run(
|
|
_bwrap_argv(Path.cwd(), [py, "-I", "-c", probe], ro_binds=ro_binds),
|
|
capture_output=True, timeout=20,
|
|
)
|
|
if r.returncode == 0:
|
|
return "bwrap"
|
|
except Exception:
|
|
pass
|
|
|
|
if shutil.which("unshare"):
|
|
for tier, argv in (
|
|
("unshare", ["unshare", "--user", "--map-current-user", "--net"]),
|
|
("unshare-root", ["unshare", "-rn"]),
|
|
):
|
|
try:
|
|
r = subprocess.run(
|
|
[*argv, py, "-I", "-c", probe],
|
|
capture_output=True, timeout=20,
|
|
)
|
|
if r.returncode == 0:
|
|
return tier
|
|
except Exception:
|
|
continue
|
|
|
|
return "rlimit"
|
|
|
|
|
|
UNSHARE_ARGV = {
|
|
"unshare": ["unshare", "--user", "--map-current-user", "--net"],
|
|
"unshare-root": ["unshare", "-rn"],
|
|
}
|
|
|
|
|
|
def _under(path: Path, parent: str) -> bool:
|
|
return bool(parent) and (str(path) == parent
|
|
or str(path).startswith(parent.rstrip("/") + os.sep))
|
|
|
|
|
|
def _bwrap_argv(workdir: Path, inner: Sequence[str],
|
|
ro_binds: Sequence[Path] = ()) -> List[str]:
|
|
home = os.path.expanduser("~")
|
|
argv = [
|
|
"bwrap",
|
|
"--ro-bind", "/", "/",
|
|
"--dev", "/dev",
|
|
"--proc", "/proc",
|
|
"--tmpfs", "/tmp",
|
|
]
|
|
if home and home != "/":
|
|
argv += ["--tmpfs", home]
|
|
for path in ro_binds:
|
|
if _under(path, home):
|
|
argv += ["--ro-bind", str(path), str(path)]
|
|
argv += [
|
|
"--bind", str(workdir), str(workdir),
|
|
"--chdir", str(workdir),
|
|
"--unshare-all",
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
"--",
|
|
]
|
|
return argv + list(inner)
|
|
|
|
|
|
def _nproc_ceiling() -> int:
|
|
"""A process cap that bounds a fork bomb without tripping on existing load.
|
|
|
|
RLIMIT_NPROC is enforced per real UID across the whole system, not per
|
|
process tree, so it has to sit above whatever the user is already running.
|
|
"""
|
|
try:
|
|
current = len(os.listdir("/proc"))
|
|
except OSError:
|
|
current = 512
|
|
return max(current * 2, 1024)
|
|
|
|
|
|
def _apply_rlimits(timeout: float, with_nproc: bool,
|
|
as_bytes: Optional[int] = LIMIT_AS_BYTES):
|
|
def _preexec():
|
|
os.setsid()
|
|
cpu = int(timeout) + LIMIT_CPU_SECONDS_SLACK
|
|
limits = [
|
|
(resource.RLIMIT_CPU, (cpu, cpu)),
|
|
(resource.RLIMIT_FSIZE, (LIMIT_FSIZE_BYTES, LIMIT_FSIZE_BYTES)),
|
|
(resource.RLIMIT_CORE, (0, 0)),
|
|
]
|
|
if as_bytes:
|
|
limits.append((resource.RLIMIT_AS, (as_bytes, as_bytes)))
|
|
if with_nproc:
|
|
ceiling = _nproc_ceiling()
|
|
limits.append((resource.RLIMIT_NPROC, (ceiling, ceiling)))
|
|
for res, lim in limits:
|
|
try:
|
|
resource.setrlimit(res, lim)
|
|
except (ValueError, OSError):
|
|
pass
|
|
return _preexec
|
|
|
|
|
|
class Sandbox:
|
|
"""A cached venv plus an isolated runner for untrusted candidate programs."""
|
|
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
packages: Optional[Sequence[str]] = None,
|
|
cache_root: Optional[Path] = None,
|
|
base_python: Optional[str] = None,
|
|
isolation: Optional[str] = None,
|
|
provision: Optional[Sequence[str]] = None,
|
|
env: Optional[Dict[str, str]] = None,
|
|
address_space_limit: Optional[int] = LIMIT_AS_BYTES,
|
|
):
|
|
self.name = name
|
|
self.packages = sorted(set(packages or []))
|
|
self.provision = list(provision or [])
|
|
self.env = dict(env or {})
|
|
# None drops the RLIMIT_AS cap entirely -- see _apply_rlimits
|
|
self.address_space_limit = address_space_limit
|
|
self.cache_root = Path(cache_root or DEFAULT_CACHE_ROOT)
|
|
self.base_python = base_python or sys.executable
|
|
self.venv_dir = self.cache_root / f"{name}-venv"
|
|
self._python: Optional[Path] = None
|
|
self._isolation = isolation
|
|
self._base_prefix: List[Path] = []
|
|
|
|
@property
|
|
def python(self) -> Path:
|
|
if self._python is None:
|
|
raise RuntimeError("Sandbox.ensure() must be called first")
|
|
return self._python
|
|
|
|
@property
|
|
def isolation(self) -> str:
|
|
if self._isolation is None:
|
|
if self._python:
|
|
py = str(self._python)
|
|
roots = [self.venv_dir, *(self._base_prefix or [])]
|
|
else:
|
|
py = sys.executable
|
|
roots = self._detect_base_prefix(Path(py))
|
|
self._isolation = _detect_isolation(py, roots)
|
|
return self._isolation
|
|
|
|
def _stamp_path(self) -> Path:
|
|
return self.venv_dir / ".llama-eval-stamp.json"
|
|
|
|
def _stamp_matches(self) -> bool:
|
|
try:
|
|
stamp = json.loads(self._stamp_path().read_text())
|
|
except Exception:
|
|
return False
|
|
return (
|
|
stamp.get("packages") == self.packages
|
|
and stamp.get("py") == self._base_version()
|
|
and stamp.get("provision") == self.provision
|
|
)
|
|
|
|
def _resolved_env(self) -> Dict[str, str]:
|
|
return {k: v.replace("{venv}", str(self.venv_dir)) for k, v in self.env.items()}
|
|
|
|
def _base_version(self) -> str:
|
|
r = subprocess.run(
|
|
[self.base_python, "-c",
|
|
"import sys;print('.'.join(map(str,sys.version_info[:3])))"],
|
|
capture_output=True, text=True,
|
|
)
|
|
return r.stdout.strip()
|
|
|
|
def ensure(self, force: bool = False, quiet: bool = False) -> Path:
|
|
"""Create/refresh the venv. Idempotent and cheap when already valid."""
|
|
py = self.venv_dir / "bin" / "python"
|
|
if force and self.venv_dir.exists():
|
|
shutil.rmtree(self.venv_dir)
|
|
|
|
if py.exists() and self._stamp_matches():
|
|
self._python = py
|
|
self._base_prefix = self._detect_base_prefix(py)
|
|
return py
|
|
|
|
if not quiet:
|
|
pkgs = ", ".join(self.packages) if self.packages else "stdlib only"
|
|
print(f"[sandbox] provisioning venv {self.venv_dir} ({pkgs})")
|
|
|
|
self.cache_root.mkdir(parents=True, exist_ok=True)
|
|
if self.venv_dir.exists():
|
|
shutil.rmtree(self.venv_dir)
|
|
|
|
uv = shutil.which("uv")
|
|
if uv:
|
|
subprocess.run(
|
|
[uv, "venv", "--python", self.base_python, str(self.venv_dir)],
|
|
check=True, capture_output=True,
|
|
)
|
|
elif Path(self.base_python).resolve() == Path(sys.executable).resolve():
|
|
venv.EnvBuilder(with_pip=bool(self.packages), clear=True).create(self.venv_dir)
|
|
else:
|
|
subprocess.run(
|
|
[self.base_python, "-m", "venv", str(self.venv_dir)],
|
|
check=True, capture_output=True,
|
|
)
|
|
|
|
if self.packages:
|
|
if uv:
|
|
cmd = [uv, "pip", "install", "--python", str(py), *self.packages]
|
|
else:
|
|
cmd = [str(py), "-m", "pip", "install", "--quiet",
|
|
"--disable-pip-version-check", *self.packages]
|
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(
|
|
f"failed to install {len(self.packages)} package(s) into "
|
|
f"{self.venv_dir}:\n{(r.stderr or r.stdout)[-3000:]}"
|
|
)
|
|
|
|
for i, snippet in enumerate(self.provision, 1):
|
|
if not quiet:
|
|
print(f"[sandbox] provisioning step {i}/{len(self.provision)}")
|
|
prov_env = dict(os.environ)
|
|
prov_env["SANDBOX_VENV"] = str(self.venv_dir)
|
|
prov_env.update(self._resolved_env())
|
|
r = subprocess.run([str(py), "-c", snippet], env=prov_env,
|
|
capture_output=True, text=True, timeout=1800)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(
|
|
f"provisioning step {i} failed:\n{(r.stderr or r.stdout)[-3000:]}"
|
|
)
|
|
|
|
self._stamp_path().write_text(json.dumps({
|
|
"packages": self.packages,
|
|
"py": self._base_version(),
|
|
"provision": self.provision,
|
|
}))
|
|
self._python = py
|
|
self._base_prefix = self._detect_base_prefix(py)
|
|
if not quiet:
|
|
print(f"[sandbox] ready ({self.isolation} isolation)")
|
|
if self.isolation == "unshare-root":
|
|
print("Warning: this host's unshare cannot keep the caller's uid, so "
|
|
"candidate code runs as root inside the namespace. Tests that "
|
|
"assert a permission denial will not see one. Install "
|
|
"bubblewrap (bwrap) for correct results.")
|
|
return py
|
|
|
|
@staticmethod
|
|
def _detect_base_prefix(py: Path) -> List[Path]:
|
|
"""Every directory that must stay visible for the venv to start.
|
|
|
|
A venv is only symlinks into its base interpreter, so the whole install
|
|
tree has to survive the tmpfs that hides $HOME -- and binding just the
|
|
resolved tree is not enough. Version-managed interpreters (uv, pyenv)
|
|
are commonly reached through an unversioned alias symlink
|
|
(cpython-3.13-... -> cpython-3.13.14-...), and startup resolves through
|
|
the alias path, so both spellings need to be mounted. Walk the chain
|
|
and keep every install root it passes through.
|
|
"""
|
|
roots: List[Path] = []
|
|
|
|
def add(path: Path):
|
|
if path.exists() and path not in roots:
|
|
roots.append(path)
|
|
|
|
cur, hops = py, 0
|
|
while hops < 16:
|
|
hops += 1
|
|
add(cur.parent.parent)
|
|
if cur.is_symlink():
|
|
target = Path(os.readlink(cur))
|
|
cur = target if target.is_absolute() else (cur.parent / target)
|
|
else:
|
|
break
|
|
|
|
try:
|
|
r = subprocess.run([str(py), "-c", "import sys; print(sys.base_prefix)"],
|
|
capture_output=True, text=True, timeout=60)
|
|
if r.returncode == 0 and r.stdout.strip():
|
|
add(Path(r.stdout.strip()))
|
|
except Exception:
|
|
pass
|
|
|
|
return [p for p in roots if str(p) not in ("/", "/usr", "/usr/local")]
|
|
|
|
def _isolate(self, argv: Sequence[str], workdir: Path,
|
|
ro_binds: Sequence[Path] = ()) -> List[str]:
|
|
"""Wrap a command in the strongest isolation tier this host supports."""
|
|
if self.isolation == "bwrap":
|
|
return _bwrap_argv(
|
|
workdir, argv,
|
|
ro_binds=[self.venv_dir, *(self._base_prefix or []), *ro_binds],
|
|
)
|
|
if self.isolation in UNSHARE_ARGV:
|
|
return [*UNSHARE_ARGV[self.isolation], *argv]
|
|
return list(argv)
|
|
|
|
def _base_env(self, workdir: Path) -> Dict[str, str]:
|
|
env = {
|
|
"PATH": "/usr/bin:/bin",
|
|
"HOME": str(workdir),
|
|
"TMPDIR": str(workdir),
|
|
"LC_ALL": "C.UTF-8",
|
|
"PYTHONHASHSEED": "0",
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
"OPENBLAS_NUM_THREADS": "1",
|
|
"OMP_NUM_THREADS": "1",
|
|
}
|
|
env.update(self._resolved_env())
|
|
return env
|
|
|
|
def run_argv(self, argv: Sequence[str], workdir: Path,
|
|
timeout: float = 15.0, env: Optional[Dict[str, str]] = None,
|
|
ro_binds: Sequence[Path] = ()) -> ExecResult:
|
|
"""Run an arbitrary command in `workdir`, which stays writable.
|
|
|
|
Unlike run(), the caller owns the directory and it survives the call.
|
|
That is what a multi-step agent needs: its edits have to persist across
|
|
many tool invocations before anything is graded. Taking an argv rather
|
|
than a source string is what lets a suite drive a linter or a test
|
|
runner for a language other than Python.
|
|
"""
|
|
run_env = self._base_env(workdir)
|
|
if env:
|
|
run_env.update(env)
|
|
|
|
started = time.monotonic()
|
|
try:
|
|
proc = subprocess.Popen(
|
|
self._isolate(argv, workdir, ro_binds),
|
|
cwd=str(workdir),
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
errors="replace",
|
|
env=run_env,
|
|
preexec_fn=_apply_rlimits(timeout,
|
|
with_nproc=self.isolation == "rlimit",
|
|
as_bytes=self.address_space_limit),
|
|
)
|
|
except Exception as e:
|
|
return ExecResult(False, "error", "", f"{type(e).__name__}: {e}")
|
|
|
|
try:
|
|
out, err = proc.communicate(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
_kill_tree(proc)
|
|
out, err = proc.communicate()
|
|
return ExecResult(False, "timeout", out or "", err or "",
|
|
None, time.monotonic() - started)
|
|
|
|
duration = time.monotonic() - started
|
|
if proc.returncode == 0:
|
|
return ExecResult(True, "ok", out, err, 0, duration)
|
|
return ExecResult(False, "failed", out, err, proc.returncode, duration)
|
|
|
|
def run(self, code: str, timeout: float = 15.0,
|
|
env: Optional[Dict[str, str]] = None) -> ExecResult:
|
|
"""Run `code` to completion in the sandbox. Exit 0 == passed."""
|
|
workdir = Path(tempfile.mkdtemp(prefix=f"{self.name}-", dir="/tmp"))
|
|
try:
|
|
prog = workdir / "candidate.py"
|
|
prog.write_text(code)
|
|
return self.run_argv([str(self.python), "-I", "-B", str(prog)],
|
|
workdir, timeout=timeout, env=env)
|
|
except Exception as e:
|
|
return ExecResult(False, "error", "", f"{type(e).__name__}: {e}")
|
|
finally:
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
|
|
|
|
def _kill_tree(proc: subprocess.Popen):
|
|
"""Kill the candidate and everything it spawned."""
|
|
for sig in (signal.SIGKILL,):
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), sig)
|
|
except (ProcessLookupError, PermissionError):
|
|
try:
|
|
proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|