verifiers 0.3.2.dev167__py3-none-any.whl → 0.3.2.dev169__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.
- verifiers/v1/rollout.py +35 -1
- verifiers/v1/runtimes/__init__.py +9 -2
- verifiers/v1/runtimes/base.py +19 -1
- verifiers/v1/runtimes/docker/__init__.py +2 -0
- verifiers/v1/runtimes/e2b.py +711 -0
- verifiers/v1/runtimes/prime.py +5 -0
- verifiers/v1/task.py +4 -0
- verifiers/v1/tasksets/harbor/taskset.py +1 -0
- {verifiers-0.3.2.dev167.dist-info → verifiers-0.3.2.dev169.dist-info}/METADATA +4 -2
- {verifiers-0.3.2.dev167.dist-info → verifiers-0.3.2.dev169.dist-info}/RECORD +13 -12
- {verifiers-0.3.2.dev167.dist-info → verifiers-0.3.2.dev169.dist-info}/WHEEL +0 -0
- {verifiers-0.3.2.dev167.dist-info → verifiers-0.3.2.dev169.dist-info}/entry_points.txt +0 -0
- {verifiers-0.3.2.dev167.dist-info → verifiers-0.3.2.dev169.dist-info}/licenses/LICENSE +0 -0
verifiers/v1/rollout.py
CHANGED
|
@@ -14,6 +14,7 @@ from verifiers.v1.configs.runtime import NetworkPolicyConfig
|
|
|
14
14
|
from verifiers.v1.errors import (
|
|
15
15
|
HarnessError,
|
|
16
16
|
RolloutError,
|
|
17
|
+
SandboxError,
|
|
17
18
|
TaskError,
|
|
18
19
|
ToolsetError,
|
|
19
20
|
boundary,
|
|
@@ -52,6 +53,39 @@ class RolloutTimeouts:
|
|
|
52
53
|
"""Timeout (in seconds) for the task + harness metrics + scoring hooks."""
|
|
53
54
|
|
|
54
55
|
|
|
56
|
+
_HOME_PROBE = (
|
|
57
|
+
"home=$(awk -F: -v u=\"$1\" '$1 == u { print $6 }' /etc/passwd); "
|
|
58
|
+
'echo "$home"; test -n "$home" && test -d "$home"'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def agent_runtime(runtime: Runtime, user: str | None) -> Runtime:
|
|
63
|
+
"""The runtime the harness runs on: `runtime` itself, or a view running as `user`
|
|
64
|
+
with `HOME` set to its home. `user` must exist with a home directory it can write
|
|
65
|
+
(task authors ship it in the image), and the view must really run as it, so a
|
|
66
|
+
runtime ignoring the user never runs the agent as root."""
|
|
67
|
+
if user is None:
|
|
68
|
+
return runtime
|
|
69
|
+
lookup = await runtime.run(["sh", "-c", _HOME_PROBE, "sh", user], {})
|
|
70
|
+
home = lookup.stdout.strip()
|
|
71
|
+
if not home:
|
|
72
|
+
raise TaskError(f"agent_user {user!r} does not exist in the image")
|
|
73
|
+
if lookup.exit_code != 0:
|
|
74
|
+
raise TaskError(f"agent_user {user!r} has no home directory ({home})")
|
|
75
|
+
view = runtime.with_user(user)
|
|
76
|
+
view = view.with_env({**view.env, "HOME": home})
|
|
77
|
+
probe = await view.run(["sh", "-c", 'id -un; test -w "$HOME" && echo writable'], {})
|
|
78
|
+
effective, *rest = probe.stdout.split() or [""]
|
|
79
|
+
if effective != user:
|
|
80
|
+
raise SandboxError(
|
|
81
|
+
f"{type(runtime).__name__} ran as {effective!r} instead of agent_user "
|
|
82
|
+
f"{user!r}; refusing to run the agent"
|
|
83
|
+
)
|
|
84
|
+
if rest != ["writable"]:
|
|
85
|
+
raise TaskError(f"agent_user {user!r} cannot write its home directory ({home})")
|
|
86
|
+
return view
|
|
87
|
+
|
|
88
|
+
|
|
55
89
|
class Rollout:
|
|
56
90
|
"""Manages one rollout's lifecycle (open, step, close)."""
|
|
57
91
|
|
|
@@ -341,7 +375,7 @@ class Rollout:
|
|
|
341
375
|
self._harness_session = await self.harness.session(
|
|
342
376
|
self.ctx,
|
|
343
377
|
self.trace,
|
|
344
|
-
runtime,
|
|
378
|
+
await agent_runtime(runtime, harness_data.agent_user),
|
|
345
379
|
self._endpoint,
|
|
346
380
|
self._secret,
|
|
347
381
|
self._urls,
|
|
@@ -25,6 +25,7 @@ from verifiers.v1.runtimes.docker import (
|
|
|
25
25
|
PodmanRuntime,
|
|
26
26
|
PodmanRuntimeInfo,
|
|
27
27
|
)
|
|
28
|
+
from verifiers.v1.runtimes.e2b import E2BConfig, E2BRuntime, E2BRuntimeInfo
|
|
28
29
|
from verifiers.v1.runtimes.modal import ModalConfig, ModalRuntime, ModalRuntimeInfo
|
|
29
30
|
from verifiers.v1.runtimes.prime import (
|
|
30
31
|
PrimeConfig,
|
|
@@ -44,7 +45,8 @@ RuntimeConfig = Annotated[
|
|
|
44
45
|
| PodmanConfig
|
|
45
46
|
| ApptainerConfig
|
|
46
47
|
| PrimeConfig
|
|
47
|
-
| ModalConfig
|
|
48
|
+
| ModalConfig
|
|
49
|
+
| E2BConfig,
|
|
48
50
|
Field(discriminator="type"),
|
|
49
51
|
]
|
|
50
52
|
|
|
@@ -54,7 +56,8 @@ RuntimeInfo = Annotated[
|
|
|
54
56
|
| PodmanRuntimeInfo
|
|
55
57
|
| ApptainerRuntimeInfo
|
|
56
58
|
| PrimeRuntimeInfo
|
|
57
|
-
| ModalRuntimeInfo
|
|
59
|
+
| ModalRuntimeInfo
|
|
60
|
+
| E2BRuntimeInfo,
|
|
58
61
|
Field(discriminator="type"),
|
|
59
62
|
]
|
|
60
63
|
|
|
@@ -67,6 +70,7 @@ def _runtime_cls(config: RuntimeConfig) -> type[Runtime]:
|
|
|
67
70
|
"apptainer": ApptainerRuntime,
|
|
68
71
|
"prime": PrimeRuntime,
|
|
69
72
|
"modal": ModalRuntime,
|
|
73
|
+
"e2b": E2BRuntime,
|
|
70
74
|
}[config.type]
|
|
71
75
|
|
|
72
76
|
|
|
@@ -109,6 +113,9 @@ __all__ = [
|
|
|
109
113
|
"DockerConfig",
|
|
110
114
|
"DockerRuntime",
|
|
111
115
|
"DockerRuntimeInfo",
|
|
116
|
+
"E2BConfig",
|
|
117
|
+
"E2BRuntime",
|
|
118
|
+
"E2BRuntimeInfo",
|
|
112
119
|
"ModalConfig",
|
|
113
120
|
"ModalRuntime",
|
|
114
121
|
"ModalRuntimeInfo",
|
verifiers/v1/runtimes/base.py
CHANGED
|
@@ -136,13 +136,16 @@ class BaseRuntimeInfo(BaseConfig):
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
class Runtime(ABC):
|
|
139
|
-
__slots__ = ("env",)
|
|
139
|
+
__slots__ = ("env", "user")
|
|
140
140
|
|
|
141
141
|
is_local: bool = True
|
|
142
142
|
"""Whether this runtime exchanges host-local URLs without a public tunnel. True for
|
|
143
143
|
subprocess and the local container runtimes; remote runtimes override to False and
|
|
144
144
|
use a host `Tunnel` inward plus `expose` outward."""
|
|
145
145
|
|
|
146
|
+
supports_user: ClassVar[bool] = False
|
|
147
|
+
"""Whether `with_user` can run this runtime's commands as another existing user."""
|
|
148
|
+
|
|
146
149
|
scripts_dir: ClassVar[str] = "/tmp/vf-scripts"
|
|
147
150
|
"""Digest-keyed PEP 723 scripts inside the runtime. Sandboxes own their `/tmp`;
|
|
148
151
|
the host subprocess runtime overrides this with its per-user cache."""
|
|
@@ -159,6 +162,8 @@ class Runtime(ABC):
|
|
|
159
162
|
# Per-run task values live on the runtime rather than its serializable config/info.
|
|
160
163
|
# Explicit process values (model credentials, proxy settings, etc.) override these.
|
|
161
164
|
self.env: dict[str, str] = {}
|
|
165
|
+
self.user: str | None = None
|
|
166
|
+
"""Existing user this view runs commands as; None uses the runtime's default."""
|
|
162
167
|
self._uv_interpreters: dict[str, str] = {}
|
|
163
168
|
self._uv_script_locks: dict[str, asyncio.Lock] = {}
|
|
164
169
|
self._mcp_sources: set[str] = set()
|
|
@@ -215,6 +220,19 @@ class Runtime(ABC):
|
|
|
215
220
|
runtime.env = dict(env)
|
|
216
221
|
return runtime
|
|
217
222
|
|
|
223
|
+
def with_user(self, user: str | None) -> "Runtime":
|
|
224
|
+
"""Share this physical runtime through a view that runs commands as `user`, an
|
|
225
|
+
existing user in the box (None keeps the default). Only the view runs as `user`:
|
|
226
|
+
setup, collection, and grading keep using the original runtime."""
|
|
227
|
+
if user is not None and not self.supports_user:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"{type(self).__name__} cannot run commands as another user"
|
|
230
|
+
)
|
|
231
|
+
runtime = copy.copy(self)
|
|
232
|
+
runtime.__dict__ = self.__dict__
|
|
233
|
+
runtime.user = user
|
|
234
|
+
return runtime
|
|
235
|
+
|
|
218
236
|
async def alive(self) -> bool:
|
|
219
237
|
"""Whether the box still executes anything. Not every runtime raises when
|
|
220
238
|
the box is gone — some surface it as `exec`'s own non-zero result,
|
|
@@ -70,6 +70,7 @@ control.sendmsg([b"listener"], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.arr
|
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
class DockerRuntime(ContainerRuntime):
|
|
73
|
+
supports_user: ClassVar[bool] = True
|
|
73
74
|
engine: ClassVar[str] = "docker"
|
|
74
75
|
"""The CLI binary for the shared OCI container operations."""
|
|
75
76
|
info_cls: ClassVar[type[BaseRuntimeInfo]] = DockerRuntimeInfo
|
|
@@ -529,6 +530,7 @@ class DockerRuntime(ContainerRuntime):
|
|
|
529
530
|
"exec",
|
|
530
531
|
*(("-i",) if stdin else ()),
|
|
531
532
|
*(arg for key, value in env.items() for arg in ("--env", f"{key}={value}")),
|
|
533
|
+
*(("--user", self.user) if self.user is not None else ()),
|
|
532
534
|
"--workdir",
|
|
533
535
|
self.config.workdir,
|
|
534
536
|
self._container,
|
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Remote E2B sandbox runtime.
|
|
2
|
+
|
|
3
|
+
`expose` (sandbox port -> public URL) uses E2B's built-in port proxy (`get_host`), so a
|
|
4
|
+
host-side harness/framework can reach a tool server hosted in the sandbox. The reverse
|
|
5
|
+
direction (a program in the sandbox reaching a host service) is the shared host-side
|
|
6
|
+
`Tunnel` (interception.tunnel), not the runtime's concern.
|
|
7
|
+
|
|
8
|
+
Two E2B SDK gaps shape the command plumbing here: every command runs through a login
|
|
9
|
+
shell (``bash -l -c``, so profile output can pollute the streams — fenced by the start
|
|
10
|
+
marker `_command` emits and consumers strip), and command output reaches callbacks as
|
|
11
|
+
UTF-8 text with ``errors="replace"`` (lossy for bytes — `open_process` swaps the
|
|
12
|
+
handle's decoders to latin-1 to recover exact bytes; see `E2BProcess`).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import codecs
|
|
17
|
+
import contextlib
|
|
18
|
+
import fcntl
|
|
19
|
+
import hashlib
|
|
20
|
+
import ipaddress
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shlex
|
|
26
|
+
import uuid
|
|
27
|
+
from collections.abc import AsyncIterator
|
|
28
|
+
from contextlib import asynccontextmanager
|
|
29
|
+
from pathlib import PurePosixPath
|
|
30
|
+
from typing import ClassVar, Literal
|
|
31
|
+
from urllib.parse import urlsplit
|
|
32
|
+
|
|
33
|
+
from pydantic import Field, model_validator
|
|
34
|
+
|
|
35
|
+
from verifiers.v1.configs.runtime import NetworkPolicyConfig
|
|
36
|
+
from verifiers.v1.errors import SandboxError
|
|
37
|
+
from verifiers.v1.runtimes.base import (
|
|
38
|
+
SERVICE_PORT,
|
|
39
|
+
BaseRuntimeInfo,
|
|
40
|
+
ProgramResult,
|
|
41
|
+
Runtime,
|
|
42
|
+
RuntimeProcess,
|
|
43
|
+
)
|
|
44
|
+
from verifiers.v1.runtimes.limiters import creation_limiter
|
|
45
|
+
from verifiers.v1.utils.aio import run_shielded
|
|
46
|
+
from verifiers.v1.utils.paths import CACHE_DIR
|
|
47
|
+
from verifiers.v1.utils.scope import run_scope
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
_TEMPLATE_SCHEMA = 1
|
|
52
|
+
_TEMPLATE_LOCK_DIR = CACHE_DIR / "e2b-template-locks"
|
|
53
|
+
|
|
54
|
+
# Run as the image's root like the sibling runtimes; the SDK's default is an
|
|
55
|
+
# unprivileged `user`, which breaks root-only setup steps (package installs, restores).
|
|
56
|
+
_USER = "root"
|
|
57
|
+
|
|
58
|
+
# `run_program` can reconnect to the same pid without losing its file-backed output, so
|
|
59
|
+
# each wait-stream drop gets its own budget of reconnect attempts (backing off from
|
|
60
|
+
# `_WAIT_RECONNECT_BACKOFF` seconds, doubling per attempt): a rollout that runs for
|
|
61
|
+
# hours may survive any number of drops as long as each one reconnects. Live process
|
|
62
|
+
# streams cannot reconnect safely: E2B does not replay missed bytes.
|
|
63
|
+
_WAIT_RECONNECTS = 4
|
|
64
|
+
_WAIT_RECONNECT_BACKOFF = 0.25
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _template_resources(cpu: float, memory: float) -> tuple[int, int]:
|
|
68
|
+
"""Validate and convert the resource units shared with TaskData.resources."""
|
|
69
|
+
cpu_value = float(cpu)
|
|
70
|
+
if not cpu_value.is_integer():
|
|
71
|
+
raise ValueError(f"E2B templates require whole CPU cores, got {cpu_value:g}")
|
|
72
|
+
cpu_count = int(cpu_value)
|
|
73
|
+
if cpu_count != 1 and cpu_count % 2:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"E2B templates require 1 or an even number of CPU cores, got {cpu_count}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
memory_mb_value = float(memory) * 1024
|
|
79
|
+
if not memory_mb_value.is_integer() or int(memory_mb_value) % 2:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"E2B template memory must resolve to an even whole number of MB, "
|
|
82
|
+
f"got {memory:g} GB ({memory_mb_value:g} MB)"
|
|
83
|
+
)
|
|
84
|
+
return cpu_count, int(memory_mb_value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_HOSTNAME = re.compile(
|
|
88
|
+
r"(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*"
|
|
89
|
+
r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?",
|
|
90
|
+
re.IGNORECASE,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_address(rule: str) -> bool:
|
|
95
|
+
try:
|
|
96
|
+
ipaddress.ip_network(rule, strict=False)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_egress_rules(allow: list[str], block: list[str]) -> None:
|
|
103
|
+
"""Validate the two selector shapes E2B's network API accepts: allow rules take
|
|
104
|
+
hostnames (a leading ``*.`` wildcard included), IPs, and CIDRs; deny rules take IPs
|
|
105
|
+
and CIDRs only."""
|
|
106
|
+
for rule in block:
|
|
107
|
+
if not _is_address(rule):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"E2B block rules must be IP addresses or CIDR blocks, got {rule!r}"
|
|
110
|
+
)
|
|
111
|
+
for rule in allow:
|
|
112
|
+
if not (_is_address(rule) or _HOSTNAME.fullmatch(rule)):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
"E2B allow rules must be hostnames, wildcard hostnames, IP addresses, "
|
|
115
|
+
f"or CIDR blocks (no schemes, ports, or paths), got {rule!r}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _egress_update(config: "E2BConfig", routes: list[str] | None) -> dict:
|
|
120
|
+
"""The complete egress policy for `update_network`, which replaces all rules
|
|
121
|
+
atomically. None restores unrestricted egress for another trusted setup phase;
|
|
122
|
+
otherwise `routes` (the interception and MCP endpoints) stay reachable alongside
|
|
123
|
+
the configured allowlist. E2B resolves an `allow_out` entry over a same-host
|
|
124
|
+
`deny_out` one and requires the deny-everything floor whenever `allow_out` names
|
|
125
|
+
domains, so an allowlist is expressed as allow + deny-everything and a blocklist
|
|
126
|
+
as deny only (everything else, framework routes included, stays reachable)."""
|
|
127
|
+
if routes is None:
|
|
128
|
+
return {"allow_internet_access": True}
|
|
129
|
+
if config.allow == ["*"]:
|
|
130
|
+
return {"deny_out": list(config.block)}
|
|
131
|
+
hosts = list(
|
|
132
|
+
dict.fromkeys(
|
|
133
|
+
host for route in routes if (host := urlsplit(route).hostname) is not None
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
entries = list(dict.fromkeys([*hosts, *config.allow]))
|
|
137
|
+
if not entries:
|
|
138
|
+
return {"allow_internet_access": False}
|
|
139
|
+
return {"allow_out": entries, "deny_out": ["0.0.0.0/0"]}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class E2BConfig(NetworkPolicyConfig):
|
|
143
|
+
"""Requires an E2B Pro plan: sandboxes are created with E2B's 24-hour maximum
|
|
144
|
+
lifetime, which the Base plan caps at one hour (the API rejects the request)."""
|
|
145
|
+
|
|
146
|
+
type: Literal["e2b"] = "e2b"
|
|
147
|
+
image: str = "python:3.11-slim"
|
|
148
|
+
"""Public Debian-based Docker image used to build a cached E2B template."""
|
|
149
|
+
workdir: str | None = None
|
|
150
|
+
"""Working directory override; None uses the task's workdir, or /app."""
|
|
151
|
+
# TaskData.resources uses these units; non-default runtime config values take precedence.
|
|
152
|
+
cpu: float = Field(default=2.0, ge=1)
|
|
153
|
+
"""CPU cores. E2B templates require 1 or an even whole number."""
|
|
154
|
+
memory: float = Field(default=1.0, ge=0.125)
|
|
155
|
+
"""Memory in GB. The converted MB value must be a whole even number."""
|
|
156
|
+
disk: float = Field(default=5.0, gt=0)
|
|
157
|
+
"""Advisory disk request in GB. E2B template builds have no disk-size knob, so this
|
|
158
|
+
is accepted (so a task can declare it without a warning) but not enforced."""
|
|
159
|
+
creates_per_sec: float | None = 1.0
|
|
160
|
+
"""Pace sandbox creation to this many per second, enforced run-wide across every
|
|
161
|
+
env-server worker process (None/<= 0 disables it). Raise it to your plan's rate."""
|
|
162
|
+
|
|
163
|
+
@model_validator(mode="after")
|
|
164
|
+
def _validate_egress(self) -> "E2BConfig":
|
|
165
|
+
if self.network_restricted:
|
|
166
|
+
_validate_egress_rules(
|
|
167
|
+
[rule for rule in self.allow if rule != "*"],
|
|
168
|
+
[rule for rule in self.block if rule != "*"],
|
|
169
|
+
)
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class E2BRuntimeInfo(E2BConfig, BaseRuntimeInfo):
|
|
174
|
+
template: str | None = None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _queue_stream(
|
|
178
|
+
queue: asyncio.Queue[bytes | SandboxError | None],
|
|
179
|
+
) -> AsyncIterator[bytes]:
|
|
180
|
+
while (chunk := await queue.get()) is not None:
|
|
181
|
+
if isinstance(chunk, SandboxError):
|
|
182
|
+
raise chunk
|
|
183
|
+
yield chunk
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _byte_handle(handle):
|
|
187
|
+
"""Make a command handle deliver exact bytes to its callbacks.
|
|
188
|
+
|
|
189
|
+
The SDK decodes chunks as UTF-8 with ``errors="replace"`` (lossy for arbitrary
|
|
190
|
+
bytes) through a per-handle incremental decoder. Swapping it for latin-1 — a 1:1 map
|
|
191
|
+
of bytes onto U+00–U+FF — lets ``chunk.encode("latin-1")`` recover the bytes. The
|
|
192
|
+
swap is safe right after the handle is returned: the SDK constructs it after its
|
|
193
|
+
last await, so no chunk is decoded before the caller runs again.
|
|
194
|
+
|
|
195
|
+
TODO(e2b): drop this once the SDK grows a bytes mode for command output; it rides
|
|
196
|
+
on private attrs the guard can only prove exist, not that chunks still route
|
|
197
|
+
through them (they do on 2.35.0–2.46.x, hence the ``<3`` pin)."""
|
|
198
|
+
for name in ("_stdout_decoder", "_stderr_decoder"):
|
|
199
|
+
if not hasattr(handle, name):
|
|
200
|
+
raise SandboxError(
|
|
201
|
+
"e2b SDK command handle no longer exposes its stream decoders; "
|
|
202
|
+
"cannot deliver byte-accurate process output"
|
|
203
|
+
)
|
|
204
|
+
setattr(handle, name, codecs.getincrementaldecoder("latin-1")())
|
|
205
|
+
return handle
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class E2BProcess(RuntimeProcess):
|
|
209
|
+
"""Built in two steps: the queues (and their `on_*` callbacks) must exist before
|
|
210
|
+
`commands.run` is called with them, and the handle only exists after — so construct
|
|
211
|
+
first, then `attach` the started handle (made byte-exact by `_byte_handle`)."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, sandbox, marker: bytes) -> None:
|
|
214
|
+
self._handle = None
|
|
215
|
+
self._sandbox = sandbox
|
|
216
|
+
self._marker = marker
|
|
217
|
+
# Pre-marker buffer per stream; None once the marker has been consumed.
|
|
218
|
+
self._prefix: dict[str, bytearray | None] = {
|
|
219
|
+
"stdout": bytearray(),
|
|
220
|
+
"stderr": bytearray(),
|
|
221
|
+
}
|
|
222
|
+
self._stdout_queue: asyncio.Queue[bytes | SandboxError | None] = asyncio.Queue()
|
|
223
|
+
self._stderr_queue: asyncio.Queue[bytes | SandboxError | None] = asyncio.Queue()
|
|
224
|
+
self.stdout = _queue_stream(self._stdout_queue)
|
|
225
|
+
self.stderr = _queue_stream(self._stderr_queue)
|
|
226
|
+
self._wait_task: asyncio.Task[int] | None = None
|
|
227
|
+
self._exit_code: int | None = None
|
|
228
|
+
|
|
229
|
+
def _payload(self, stream: str, chunk: str) -> bytes:
|
|
230
|
+
"""Recover the raw bytes of a chunk and drop everything through the marker."""
|
|
231
|
+
data = chunk.encode("latin-1", errors="replace")
|
|
232
|
+
pending = self._prefix[stream]
|
|
233
|
+
if pending is None:
|
|
234
|
+
return data
|
|
235
|
+
# Resume the search where the last chunk left off (the marker can straddle
|
|
236
|
+
# a chunk boundary), so pathological pre-marker noise stays linear to scan.
|
|
237
|
+
search_from = max(0, len(pending) - len(self._marker) + 1)
|
|
238
|
+
pending.extend(data)
|
|
239
|
+
start = pending.find(self._marker, search_from)
|
|
240
|
+
if start < 0:
|
|
241
|
+
return b""
|
|
242
|
+
self._prefix[stream] = None
|
|
243
|
+
return bytes(pending[start + len(self._marker) :])
|
|
244
|
+
|
|
245
|
+
def on_stdout(self, chunk: str) -> None:
|
|
246
|
+
if data := self._payload("stdout", chunk):
|
|
247
|
+
self._stdout_queue.put_nowait(data)
|
|
248
|
+
|
|
249
|
+
def on_stderr(self, chunk: str) -> None:
|
|
250
|
+
if data := self._payload("stderr", chunk):
|
|
251
|
+
self._stderr_queue.put_nowait(data)
|
|
252
|
+
|
|
253
|
+
def attach(self, handle) -> None:
|
|
254
|
+
self._handle = handle
|
|
255
|
+
self._wait_task = asyncio.create_task(self._wait())
|
|
256
|
+
|
|
257
|
+
async def _wait(self) -> int:
|
|
258
|
+
# Importable here for sure: a live handle means the SDK import in `start` succeeded.
|
|
259
|
+
from e2b import CommandExitException
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
self._exit_code = (await self._handle.wait()).exit_code
|
|
263
|
+
except CommandExitException as e:
|
|
264
|
+
self._exit_code = e.exit_code
|
|
265
|
+
except Exception as e:
|
|
266
|
+
with contextlib.suppress(Exception):
|
|
267
|
+
await self.kill()
|
|
268
|
+
error = SandboxError(f"e2b live process connection failed: {e}")
|
|
269
|
+
self._stdout_queue.put_nowait(error)
|
|
270
|
+
self._stderr_queue.put_nowait(error)
|
|
271
|
+
raise error from e
|
|
272
|
+
finally:
|
|
273
|
+
# A process that died before printing its marker produced only pre-marker
|
|
274
|
+
# output — the shell's own diagnosis of why it never started. Flush it so
|
|
275
|
+
# the failure is debuggable (mirrors run()'s missing-marker fallback).
|
|
276
|
+
for queue, stream in (
|
|
277
|
+
(self._stdout_queue, "stdout"),
|
|
278
|
+
(self._stderr_queue, "stderr"),
|
|
279
|
+
):
|
|
280
|
+
if pending := self._prefix[stream]:
|
|
281
|
+
queue.put_nowait(bytes(pending))
|
|
282
|
+
queue.put_nowait(None)
|
|
283
|
+
return self._exit_code
|
|
284
|
+
|
|
285
|
+
async def write(self, data: bytes) -> None:
|
|
286
|
+
try:
|
|
287
|
+
await self._handle.send_stdin(data)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
raise SandboxError(f"e2b live process stdin failed: {e}") from e
|
|
290
|
+
|
|
291
|
+
async def wait(self) -> int:
|
|
292
|
+
assert self._wait_task is not None
|
|
293
|
+
return await asyncio.shield(self._wait_task)
|
|
294
|
+
|
|
295
|
+
async def poll(self) -> int | None:
|
|
296
|
+
if self._wait_task is not None and self._wait_task.done():
|
|
297
|
+
return self._wait_task.result()
|
|
298
|
+
return self._exit_code
|
|
299
|
+
|
|
300
|
+
async def terminate(self) -> None:
|
|
301
|
+
await self._signal("TERM")
|
|
302
|
+
|
|
303
|
+
async def kill(self) -> None:
|
|
304
|
+
await self._signal("KILL")
|
|
305
|
+
|
|
306
|
+
async def _signal(self, signal: str) -> None:
|
|
307
|
+
if self._exit_code is not None:
|
|
308
|
+
return
|
|
309
|
+
try:
|
|
310
|
+
await self._sandbox.commands.run(
|
|
311
|
+
f"kill -{signal} {self._handle.pid}", user=_USER
|
|
312
|
+
)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
if self._exit_code is None:
|
|
315
|
+
raise SandboxError(f"e2b live process signal failed: {e}") from e
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@asynccontextmanager
|
|
319
|
+
async def _template_lock(name: str):
|
|
320
|
+
"""Serialize a deterministic template build across this user's worker processes."""
|
|
321
|
+
_TEMPLATE_LOCK_DIR.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
fd = os.open(_TEMPLATE_LOCK_DIR / f"{name}.lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
323
|
+
try:
|
|
324
|
+
# Wait without occupying executor threads needed by the lock holder.
|
|
325
|
+
while True:
|
|
326
|
+
try:
|
|
327
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
328
|
+
break
|
|
329
|
+
except BlockingIOError:
|
|
330
|
+
await asyncio.sleep(0.1)
|
|
331
|
+
yield
|
|
332
|
+
finally:
|
|
333
|
+
# Closing releases the lock, including when the task is cancelled.
|
|
334
|
+
os.close(fd)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class E2BRuntime(Runtime):
|
|
338
|
+
is_local: ClassVar[bool] = False
|
|
339
|
+
|
|
340
|
+
def __init__(self, config: E2BConfig, name: str | None = None) -> None:
|
|
341
|
+
super().__init__(name)
|
|
342
|
+
self.config = config.model_copy(update={"workdir": config.workdir or "/app"})
|
|
343
|
+
self.info = E2BRuntimeInfo(**self.config.model_dump())
|
|
344
|
+
self._sandbox = None
|
|
345
|
+
# Start marker for `_command`'s login-shell fence; random so no login-shell
|
|
346
|
+
# output can contain it (see `_command`).
|
|
347
|
+
self._marker = f"vf-start-{uuid.uuid4().hex}"
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def published_port(self) -> int | None:
|
|
351
|
+
return SERVICE_PORT
|
|
352
|
+
|
|
353
|
+
def _template_name(self) -> str:
|
|
354
|
+
definition = json.dumps(
|
|
355
|
+
{
|
|
356
|
+
"schema": _TEMPLATE_SCHEMA,
|
|
357
|
+
"image": self.config.image,
|
|
358
|
+
"cpu": self.config.cpu,
|
|
359
|
+
"memory": self.config.memory,
|
|
360
|
+
},
|
|
361
|
+
sort_keys=True,
|
|
362
|
+
separators=(",", ":"),
|
|
363
|
+
)
|
|
364
|
+
digest = hashlib.sha256(definition.encode()).hexdigest()[:24]
|
|
365
|
+
return f"vf-{digest}"
|
|
366
|
+
|
|
367
|
+
async def _ensure_template(self) -> str:
|
|
368
|
+
from e2b import AsyncTemplate, Template
|
|
369
|
+
|
|
370
|
+
# Validated here, not in `__init__`: task-level resources arrive unvalidated
|
|
371
|
+
# (model_copy) and a bad task should fail its own rollout, like on other providers.
|
|
372
|
+
cpu_count, memory_mb = _template_resources(self.config.cpu, self.config.memory)
|
|
373
|
+
name = self._template_name()
|
|
374
|
+
async with _template_lock(name):
|
|
375
|
+
# An alias is registered before its build finishes; an explicit tag
|
|
376
|
+
# exists only when it resolves to a ready build.
|
|
377
|
+
if not await AsyncTemplate.exists(f"{name}:default"):
|
|
378
|
+
logger.info(
|
|
379
|
+
"e2b: building template %s from image %s", name, self.config.image
|
|
380
|
+
)
|
|
381
|
+
template = Template().from_image(self.config.image)
|
|
382
|
+
await AsyncTemplate.build(
|
|
383
|
+
template,
|
|
384
|
+
name,
|
|
385
|
+
cpu_count=cpu_count,
|
|
386
|
+
memory_mb=memory_mb,
|
|
387
|
+
)
|
|
388
|
+
return name
|
|
389
|
+
|
|
390
|
+
async def start(self) -> None:
|
|
391
|
+
try:
|
|
392
|
+
from e2b import AsyncSandbox
|
|
393
|
+
except ModuleNotFoundError as e:
|
|
394
|
+
raise ModuleNotFoundError(
|
|
395
|
+
"E2BRuntime requires the E2B SDK; install `verifiers[e2b]`."
|
|
396
|
+
) from e
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
template = await self._ensure_template()
|
|
400
|
+
self.info.template = template
|
|
401
|
+
|
|
402
|
+
async def _create() -> None:
|
|
403
|
+
async with (
|
|
404
|
+
creation_limiter(
|
|
405
|
+
self.config.creates_per_sec, "e2b-sandbox", run_scope()
|
|
406
|
+
)
|
|
407
|
+
or contextlib.nullcontext()
|
|
408
|
+
):
|
|
409
|
+
# Created unrestricted: setup (uv installs, task staging) needs open
|
|
410
|
+
# egress; `prepare_execution` locks the policy down before the agent.
|
|
411
|
+
self._sandbox = await AsyncSandbox.create(
|
|
412
|
+
template,
|
|
413
|
+
# Maximum lifetime of any sandbox (Pro; Base's 1h cap rejects it).
|
|
414
|
+
timeout=24 * 60 * 60,
|
|
415
|
+
metadata={"runtime": "verifiers", "name": self.name},
|
|
416
|
+
)
|
|
417
|
+
# The atexit backstop kills by id, so record it the moment the sandbox
|
|
418
|
+
# exists — the cancellation `run_shielded` re-raises can keep the code
|
|
419
|
+
# after it from ever running.
|
|
420
|
+
self.info.id = self._sandbox.sandbox_id
|
|
421
|
+
|
|
422
|
+
# Finish an accepted create request even if the rollout is cancelled so the
|
|
423
|
+
# surrounding provision_runtime finally can see and kill the sandbox.
|
|
424
|
+
await run_shielded(_create())
|
|
425
|
+
logger.info(
|
|
426
|
+
"e2b: sandbox %s up (image=%s, template=%s)",
|
|
427
|
+
self.info.id,
|
|
428
|
+
self.config.image,
|
|
429
|
+
template,
|
|
430
|
+
)
|
|
431
|
+
# The template is workdir-agnostic (one build serves every task workdir);
|
|
432
|
+
# every command passes `cwd`, so only the directory itself is needed.
|
|
433
|
+
await self._sandbox.files.make_dir(self.config.workdir, user=_USER)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
raise SandboxError(f"e2b sandbox provisioning failed: {e}") from e
|
|
436
|
+
|
|
437
|
+
async def prepare_execution(self, routes: list[str] | None) -> None:
|
|
438
|
+
"""Apply the host policy after setup; the update replaces all rules atomically."""
|
|
439
|
+
if not self.network_restricted:
|
|
440
|
+
return
|
|
441
|
+
network = _egress_update(self.config, routes)
|
|
442
|
+
try:
|
|
443
|
+
await self._sandbox.update_network(network)
|
|
444
|
+
except Exception as e:
|
|
445
|
+
raise SandboxError(f"e2b egress policy failed: {e}") from e
|
|
446
|
+
logger.info(
|
|
447
|
+
"e2b: egress policy applied on sandbox %s (%s)", self.info.id, network
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def _command(
|
|
451
|
+
self, argv: list[str], env: dict[str, str]
|
|
452
|
+
) -> tuple[str, dict[str, str]]:
|
|
453
|
+
"""The wrapped command and its env.
|
|
454
|
+
|
|
455
|
+
E2B commands run through a login shell (the SDK hardcodes ``bash -l -c``),
|
|
456
|
+
which brings two hazards the wrapper fences off: profile initialization can
|
|
457
|
+
print to stdout/stderr (banners in /etc/profile.d) and it re-derives PATH. The
|
|
458
|
+
runtime's start marker, printed on both streams right before ``exec``, lets
|
|
459
|
+
consumers drop everything the shell produced; ``set +x`` comes first so a
|
|
460
|
+
profile that enabled tracing cannot echo the marker or the exec line to stderr
|
|
461
|
+
after it. The caller-supplied PATH is restored after initialization so it has
|
|
462
|
+
the same precedence as on other runtimes."""
|
|
463
|
+
command_env = self.process_env(env)
|
|
464
|
+
emit = f"printf %s {shlex.quote(self._marker)}"
|
|
465
|
+
command = f"{emit}; {emit} >&2; exec {shlex.join(argv)}"
|
|
466
|
+
if "PATH" in command_env:
|
|
467
|
+
command_env["VF_RUNTIME_PATH"] = command_env.pop("PATH")
|
|
468
|
+
command = f'export PATH="$VF_RUNTIME_PATH"; {command}'
|
|
469
|
+
return f"set +x; {command}", command_env
|
|
470
|
+
|
|
471
|
+
def _after_marker(self, text: str) -> str:
|
|
472
|
+
"""Drop login-shell noise: everything through the start marker. Text without
|
|
473
|
+
the marker (the shell died before ``exec``) passes through whole — it is the
|
|
474
|
+
diagnosis of why the command never ran."""
|
|
475
|
+
_, sep, tail = text.partition(self._marker)
|
|
476
|
+
return tail if sep else text
|
|
477
|
+
|
|
478
|
+
async def run(self, argv: list[str], env: dict[str, str]) -> ProgramResult:
|
|
479
|
+
from e2b import CommandExitException # importable once `start` succeeded
|
|
480
|
+
|
|
481
|
+
command, command_env = self._command(argv, env)
|
|
482
|
+
try:
|
|
483
|
+
result = await self._sandbox.commands.run(
|
|
484
|
+
command,
|
|
485
|
+
envs=command_env,
|
|
486
|
+
cwd=self.config.workdir,
|
|
487
|
+
user=_USER,
|
|
488
|
+
timeout=0, # no command deadline; the agent's own timeouts govern
|
|
489
|
+
)
|
|
490
|
+
except CommandExitException as e: # non-zero exit is a result, not a failure
|
|
491
|
+
result = e
|
|
492
|
+
except Exception as e:
|
|
493
|
+
raise SandboxError(f"e2b exec failed: {e}") from e
|
|
494
|
+
return ProgramResult(
|
|
495
|
+
result.exit_code,
|
|
496
|
+
self._after_marker(result.stdout or ""),
|
|
497
|
+
self._after_marker(result.stderr or ""),
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
async def _finished_status(self, status_path: str) -> str | None:
|
|
501
|
+
"""The wrapper's recorded exit status, or None while the program still runs.
|
|
502
|
+
Read rather than test existence: the shell truncates the file before printf
|
|
503
|
+
fills it, so only non-empty content proves the program completed."""
|
|
504
|
+
try:
|
|
505
|
+
status = await self._sandbox.files.read(status_path, user=_USER)
|
|
506
|
+
except Exception: # noqa: BLE001 - unreadable means "not finished yet"
|
|
507
|
+
return None
|
|
508
|
+
return status.strip() or None
|
|
509
|
+
|
|
510
|
+
async def _reconnect(self, pid: int, status_path: str, cause: Exception):
|
|
511
|
+
"""Reattach to a durable program's wait stream after it dropped: the handle on
|
|
512
|
+
success, None once the program is found to have finished (its status file is
|
|
513
|
+
written) — completion beats a dead stream. Raises after `_WAIT_RECONNECTS`
|
|
514
|
+
consecutive failures; a drop that reconnects consumes none of a later drop's
|
|
515
|
+
budget, so a long rollout survives any number of transient drops."""
|
|
516
|
+
error: Exception = cause
|
|
517
|
+
for attempt in range(_WAIT_RECONNECTS):
|
|
518
|
+
await asyncio.sleep(_WAIT_RECONNECT_BACKOFF * 2**attempt)
|
|
519
|
+
try:
|
|
520
|
+
return await self._sandbox.commands.connect(pid, timeout=0)
|
|
521
|
+
except Exception as reconnect_error: # noqa: BLE001 - re-raised after the loop
|
|
522
|
+
# The pid may be gone because the program finished during the backoff.
|
|
523
|
+
if await self._finished_status(status_path) is not None:
|
|
524
|
+
return None
|
|
525
|
+
error = reconnect_error
|
|
526
|
+
raise SandboxError(
|
|
527
|
+
f"e2b durable program connection failed: {cause}; reconnect failed: {error}"
|
|
528
|
+
) from error
|
|
529
|
+
|
|
530
|
+
async def run_program(self, argv: list[str], env: dict[str, str]) -> ProgramResult:
|
|
531
|
+
"""Run the rollout through a durable E2B process without ever replaying it."""
|
|
532
|
+
from e2b import CommandExitException
|
|
533
|
+
|
|
534
|
+
prefix = f"/tmp/vf-program-{uuid.uuid4().hex}"
|
|
535
|
+
stdout_path, stderr_path, status_path = (
|
|
536
|
+
f"{prefix}.stdout",
|
|
537
|
+
f"{prefix}.stderr",
|
|
538
|
+
f"{prefix}.status",
|
|
539
|
+
)
|
|
540
|
+
wrapper = (
|
|
541
|
+
'out=$1; err=$2; status=$3; shift 3; "$@" >"$out" 2>"$err"; '
|
|
542
|
+
'rc=$?; printf "%s\\n" "$rc" >"$status"; exit 0'
|
|
543
|
+
)
|
|
544
|
+
command, command_env = self._command(
|
|
545
|
+
[
|
|
546
|
+
"sh",
|
|
547
|
+
"-c",
|
|
548
|
+
wrapper,
|
|
549
|
+
"vf-program",
|
|
550
|
+
stdout_path,
|
|
551
|
+
stderr_path,
|
|
552
|
+
status_path,
|
|
553
|
+
*argv,
|
|
554
|
+
],
|
|
555
|
+
env,
|
|
556
|
+
)
|
|
557
|
+
try:
|
|
558
|
+
handle = await self._sandbox.commands.run(
|
|
559
|
+
command,
|
|
560
|
+
background=True,
|
|
561
|
+
envs=command_env,
|
|
562
|
+
cwd=self.config.workdir,
|
|
563
|
+
user=_USER,
|
|
564
|
+
stdin=False,
|
|
565
|
+
timeout=0,
|
|
566
|
+
)
|
|
567
|
+
# The status file, not the wait stream, is the source of truth: on any
|
|
568
|
+
# stream failure — including a reconnect finding the pid already gone
|
|
569
|
+
# because the program finished during the backoff — completion wins.
|
|
570
|
+
status: str | None = None
|
|
571
|
+
while True:
|
|
572
|
+
try:
|
|
573
|
+
await handle.wait()
|
|
574
|
+
break
|
|
575
|
+
except CommandExitException as e:
|
|
576
|
+
# The wrapper always exits 0, so this is the login shell failing
|
|
577
|
+
# before it ran (`exec` failure, a profile that exits): as in
|
|
578
|
+
# `run`, its exit code and streams are the result.
|
|
579
|
+
return ProgramResult(
|
|
580
|
+
e.exit_code,
|
|
581
|
+
self._after_marker(e.stdout or ""),
|
|
582
|
+
self._after_marker(e.stderr or ""),
|
|
583
|
+
)
|
|
584
|
+
except Exception as e: # noqa: BLE001 - a dropped stream; _reconnect re-raises
|
|
585
|
+
status = await self._finished_status(status_path)
|
|
586
|
+
if status is not None:
|
|
587
|
+
break
|
|
588
|
+
handle = await self._reconnect(handle.pid, status_path, e)
|
|
589
|
+
if handle is None: # finished while the stream was down
|
|
590
|
+
break
|
|
591
|
+
stdout, stderr = await asyncio.gather(
|
|
592
|
+
self._sandbox.files.read(stdout_path, user=_USER),
|
|
593
|
+
self._sandbox.files.read(stderr_path, user=_USER),
|
|
594
|
+
)
|
|
595
|
+
if status is None:
|
|
596
|
+
status = (
|
|
597
|
+
await self._sandbox.files.read(status_path, user=_USER)
|
|
598
|
+
).strip()
|
|
599
|
+
return ProgramResult(int(status), stdout, stderr)
|
|
600
|
+
except SandboxError:
|
|
601
|
+
raise
|
|
602
|
+
except Exception as e:
|
|
603
|
+
raise SandboxError(f"e2b durable program failed: {e}") from e
|
|
604
|
+
finally:
|
|
605
|
+
with contextlib.suppress(Exception):
|
|
606
|
+
await asyncio.gather(
|
|
607
|
+
self._sandbox.files.remove(stdout_path, user=_USER),
|
|
608
|
+
self._sandbox.files.remove(stderr_path, user=_USER),
|
|
609
|
+
self._sandbox.files.remove(status_path, user=_USER),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
async def open_process(
|
|
613
|
+
self, argv: list[str], env: dict[str, str]
|
|
614
|
+
) -> RuntimeProcess:
|
|
615
|
+
command, command_env = self._command(argv, env)
|
|
616
|
+
process = E2BProcess(self._sandbox, self._marker.encode())
|
|
617
|
+
try:
|
|
618
|
+
handle = await self._sandbox.commands.run(
|
|
619
|
+
command,
|
|
620
|
+
background=True,
|
|
621
|
+
envs=command_env,
|
|
622
|
+
cwd=self.config.workdir,
|
|
623
|
+
user=_USER,
|
|
624
|
+
on_stdout=process.on_stdout,
|
|
625
|
+
on_stderr=process.on_stderr,
|
|
626
|
+
stdin=True,
|
|
627
|
+
timeout=0,
|
|
628
|
+
)
|
|
629
|
+
except Exception as e:
|
|
630
|
+
raise SandboxError(f"e2b live process failed to start: {e}") from e
|
|
631
|
+
try:
|
|
632
|
+
process.attach(_byte_handle(handle))
|
|
633
|
+
except SandboxError:
|
|
634
|
+
with contextlib.suppress(Exception):
|
|
635
|
+
await handle.kill()
|
|
636
|
+
raise
|
|
637
|
+
return process
|
|
638
|
+
|
|
639
|
+
async def run_background(
|
|
640
|
+
self, argv: list[str], env: dict[str, str], log: str
|
|
641
|
+
) -> None:
|
|
642
|
+
inner = f"nohup {shlex.join(argv)} > {shlex.quote(log)} 2>&1 &"
|
|
643
|
+
result = await self.run(["sh", "-c", inner], env)
|
|
644
|
+
if result.exit_code != 0:
|
|
645
|
+
raise SandboxError(f"e2b background launch failed: {result.stderr.strip()}")
|
|
646
|
+
|
|
647
|
+
def _abs(self, path: str) -> str:
|
|
648
|
+
if path.startswith("/"):
|
|
649
|
+
return path
|
|
650
|
+
return f"{self.config.workdir.rstrip('/')}/{path}"
|
|
651
|
+
|
|
652
|
+
async def _read(self, path: str, max_bytes: int | None = None) -> bytes:
|
|
653
|
+
if max_bytes is not None:
|
|
654
|
+
return await super()._read(path, max_bytes)
|
|
655
|
+
try:
|
|
656
|
+
return bytes(
|
|
657
|
+
await self._sandbox.files.read(
|
|
658
|
+
self._abs(path), format="bytes", user=_USER
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
except Exception as e:
|
|
662
|
+
raise SandboxError(f"read {path!r}: {e}") from e
|
|
663
|
+
|
|
664
|
+
async def write(self, path: str, data: bytes) -> None:
|
|
665
|
+
target = self._abs(path)
|
|
666
|
+
try:
|
|
667
|
+
await self._sandbox.files.make_dir(
|
|
668
|
+
str(PurePosixPath(target).parent), user=_USER
|
|
669
|
+
)
|
|
670
|
+
await self._sandbox.files.write(target, data, user=_USER)
|
|
671
|
+
except Exception as e:
|
|
672
|
+
raise SandboxError(f"write {path!r}: {e}") from e
|
|
673
|
+
|
|
674
|
+
async def expose(self, port: int) -> str | None:
|
|
675
|
+
# Publish a server hosted IN the sandbox: E2B proxies every sandbox port at a
|
|
676
|
+
# public HTTPS URL derived locally from the sandbox id — nothing to declare up
|
|
677
|
+
# front or tear down.
|
|
678
|
+
if self._sandbox is None:
|
|
679
|
+
return None
|
|
680
|
+
return f"https://{self._sandbox.get_host(port)}"
|
|
681
|
+
|
|
682
|
+
def cleanup(self) -> None:
|
|
683
|
+
# Synchronous atexit backstop (the async API can't run once the loop is gone): kill
|
|
684
|
+
# the sandbox by id via E2B's sync API so the paid resource isn't left to its
|
|
685
|
+
# timeout. Idempotent — the async `stop` handles the normal path, and a second kill
|
|
686
|
+
# is a no-op on E2B's side.
|
|
687
|
+
if self._sandbox is None or self.info.id is None:
|
|
688
|
+
return
|
|
689
|
+
from e2b import Sandbox
|
|
690
|
+
|
|
691
|
+
try:
|
|
692
|
+
Sandbox.kill(self.info.id)
|
|
693
|
+
except Exception: # noqa: BLE001 - a later cleanup call may retry
|
|
694
|
+
return
|
|
695
|
+
self._sandbox = None
|
|
696
|
+
|
|
697
|
+
async def teardown(self) -> None:
|
|
698
|
+
# Best-effort, idempotent teardown on the normal path: kill the sandbox (the costly
|
|
699
|
+
# resource) via the async API. Runs via `stop`, shielded from cancellation, so it
|
|
700
|
+
# fires on success, error, and Ctrl-C. `_sandbox` — the atexit backstop's key — is
|
|
701
|
+
# consumed only after the kill attempt: if loop death (second Ctrl-C) truncates the
|
|
702
|
+
# await, the backstop can still kill by id.
|
|
703
|
+
sandbox = self._sandbox
|
|
704
|
+
if sandbox is None:
|
|
705
|
+
return
|
|
706
|
+
try:
|
|
707
|
+
await sandbox.kill()
|
|
708
|
+
except Exception as e: # noqa: BLE001 - provider teardown is best-effort
|
|
709
|
+
logger.warning("e2b: failed to kill sandbox %s: %s", self.info.id, e)
|
|
710
|
+
return
|
|
711
|
+
self._sandbox = None
|
verifiers/v1/runtimes/prime.py
CHANGED
|
@@ -144,6 +144,7 @@ class PrimeProcess(RuntimeProcess):
|
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
class PrimeRuntime(Runtime):
|
|
147
|
+
supports_user: ClassVar[bool] = True
|
|
147
148
|
is_local: ClassVar[bool] = False
|
|
148
149
|
|
|
149
150
|
def __init__(self, config: PrimeConfig, name: str | None = None) -> None:
|
|
@@ -292,6 +293,7 @@ class PrimeRuntime(Runtime):
|
|
|
292
293
|
shlex.join(argv),
|
|
293
294
|
working_dir=self.config.workdir,
|
|
294
295
|
env=self.process_env(env),
|
|
296
|
+
user=self.user,
|
|
295
297
|
)
|
|
296
298
|
delay = 0.1
|
|
297
299
|
output_retries = 0
|
|
@@ -335,6 +337,7 @@ class PrimeRuntime(Runtime):
|
|
|
335
337
|
shlex.join(argv),
|
|
336
338
|
working_dir=self.config.workdir,
|
|
337
339
|
env=self.process_env(env),
|
|
340
|
+
user=self.user,
|
|
338
341
|
)
|
|
339
342
|
except Exception as e:
|
|
340
343
|
raise SandboxError(f"prime live process failed to start: {e}") from e
|
|
@@ -356,6 +359,7 @@ class PrimeRuntime(Runtime):
|
|
|
356
359
|
command,
|
|
357
360
|
working_dir=self.config.workdir,
|
|
358
361
|
env=self.process_env(env),
|
|
362
|
+
user=self.user,
|
|
359
363
|
)
|
|
360
364
|
except Exception as e:
|
|
361
365
|
raise SandboxError(f"prime background launch failed: {e}") from e
|
|
@@ -370,6 +374,7 @@ class PrimeRuntime(Runtime):
|
|
|
370
374
|
f"head -c {max_bytes} -- {shlex.quote(path)}",
|
|
371
375
|
working_dir=self.config.workdir,
|
|
372
376
|
env=self.process_env({}),
|
|
377
|
+
user=self.user,
|
|
373
378
|
)
|
|
374
379
|
async with contextlib.aclosing(process):
|
|
375
380
|
with io.BytesIO() as data:
|
verifiers/v1/task.py
CHANGED
|
@@ -99,6 +99,10 @@ class TaskData(BaseModel):
|
|
|
99
99
|
"""Optional Docker image to use for the task. Only relevant for tasks that run in a container."""
|
|
100
100
|
workdir: str | None = None
|
|
101
101
|
"""Optional working directory to use for the task. Only relevant for tasks that run in a container."""
|
|
102
|
+
agent_user: str | None = None
|
|
103
|
+
"""Existing user in the task's box that the harness, and so every command the agent
|
|
104
|
+
runs, executes as. None uses the runtime's default user (usually root). Task setup,
|
|
105
|
+
harness setup, artifact collection, and grading keep running as the default user."""
|
|
102
106
|
|
|
103
107
|
skills: list[SkillSource] = Field(default_factory=list)
|
|
104
108
|
"""Skill sources installed before the harness's configured skills for this task."""
|
|
@@ -676,6 +676,7 @@ def parse_task(task_dir: Path, idx: int, harbor_config: HarborConfig) -> HarborD
|
|
|
676
676
|
prompt=harbor_task.instruction.strip(),
|
|
677
677
|
image=image,
|
|
678
678
|
workdir=environment.workdir,
|
|
679
|
+
agent_user=None if parsed.agent.user is None else str(parsed.agent.user),
|
|
679
680
|
network_allow=(
|
|
680
681
|
["*"]
|
|
681
682
|
if network.network_mode == NetworkMode.PUBLIC
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: verifiers
|
|
3
|
-
Version: 0.3.2.
|
|
3
|
+
Version: 0.3.2.dev169
|
|
4
4
|
Summary: Verifiers: Environments for LLM Reinforcement Learning
|
|
5
5
|
Project-URL: Homepage, https://github.com/primeintellect-ai/verifiers
|
|
6
6
|
Project-URL: Documentation, https://github.com/primeintellect-ai/verifiers
|
|
@@ -36,7 +36,7 @@ Requires-Dist: numpy>=2.1.0
|
|
|
36
36
|
Requires-Dist: openai<3.0.0,>=2.54.0
|
|
37
37
|
Requires-Dist: prime-pydantic-config[toml]>=0.4.3
|
|
38
38
|
Requires-Dist: prime-runs>=0.1.3
|
|
39
|
-
Requires-Dist: prime-sandboxes>=0.
|
|
39
|
+
Requires-Dist: prime-sandboxes>=0.4.1
|
|
40
40
|
Requires-Dist: prime-tunnel>=0.1.8
|
|
41
41
|
Requires-Dist: pydantic>=2.12.3
|
|
42
42
|
Requires-Dist: pyzmq>=27.1.0
|
|
@@ -46,6 +46,8 @@ Requires-Dist: tenacity>=8.5.0
|
|
|
46
46
|
Requires-Dist: tomli-w>=1.0.0
|
|
47
47
|
Requires-Dist: typing-extensions>=4.12.2
|
|
48
48
|
Requires-Dist: uvicorn>=0.52.0
|
|
49
|
+
Provides-Extra: e2b
|
|
50
|
+
Requires-Dist: e2b<3,>=2.35.0; extra == 'e2b'
|
|
49
51
|
Provides-Extra: harbor
|
|
50
52
|
Requires-Dist: harbor==0.21.0; (python_full_version >= '3.12') and extra == 'harbor'
|
|
51
53
|
Provides-Extra: modal
|
|
@@ -7,11 +7,11 @@ verifiers/v1/errors.py,sha256=nNabzi55J5aHmAmW9UHZVpI7kcBfy4Xcyt4cQthZbM8,5875
|
|
|
7
7
|
verifiers/v1/graph.py,sha256=LW0pJRxCCOXZa2KwB0edHIIqmRrzLFBDNyEeOFVskl8,41502
|
|
8
8
|
verifiers/v1/harness.py,sha256=AcsCmNHB1UQ75nD7RUawsb65q63NdkcMykD0E6oRDOA,15957
|
|
9
9
|
verifiers/v1/judge.py,sha256=uqn-djkK2_7Q3kOjkDo6ipOvV_HOvuLa9y6AoDjRux8,9618
|
|
10
|
-
verifiers/v1/rollout.py,sha256=
|
|
10
|
+
verifiers/v1/rollout.py,sha256=sx9ilprIu-5zrYH9G4yBWZdqzDdb_ctwLJVItLdTo88,26960
|
|
11
11
|
verifiers/v1/semantic.py,sha256=tX8FYEceuMYauuB3Bc8jLDMS2eR06JjLjTo66R190Fg,4053
|
|
12
12
|
verifiers/v1/session.py,sha256=qVMgKmqkItemR3uYUDAFuDz7huP593kHQbge8FlRba8,28420
|
|
13
13
|
verifiers/v1/state.py,sha256=EckF2bWp-vV4b1jYJ9sLI5xrfGuI5spIgYYwW926toI,595
|
|
14
|
-
verifiers/v1/task.py,sha256=
|
|
14
|
+
verifiers/v1/task.py,sha256=YN_P3912NYbK4VPTPHu6kyYCpeyoyhDVxBbhpjg8AA0,13185
|
|
15
15
|
verifiers/v1/taskset.py,sha256=LE_3ogL7itU2Amk-N20cHkZ7Ev6MznJ0HpC8B1xb4w0,9482
|
|
16
16
|
verifiers/v1/trace.py,sha256=Npc-TrBh-B3H0OvtfA5X31VAlOhfgLDKGdXohqC2uIk,31156
|
|
17
17
|
verifiers/v1/types.py,sha256=-In5plgB3mQLqtRKHC3QDCPDvuruAqVd8WIjKrsGB_I,10578
|
|
@@ -140,15 +140,16 @@ verifiers/v1/mcp/__init__.py,sha256=pm8ZWBiL1q1tY4wHvJzM_QrZ9oR8aGm-bIRWiRJsgHI,
|
|
|
140
140
|
verifiers/v1/mcp/launch.py,sha256=r2j1hxMCZtOOjbOFBCZpt1_Zpo56e83HFMg_4sB_kA4,25916
|
|
141
141
|
verifiers/v1/mcp/server.py,sha256=Jqi8OMT8DnoZegXqM3UNNRzhVnbdrPEwx0DjOJo_K2A,13256
|
|
142
142
|
verifiers/v1/mcp/toolset.py,sha256=G2wzNdEMGLXoQUcrwF0TartJkqokNPI3o7YUOAaaa6U,1007
|
|
143
|
-
verifiers/v1/runtimes/__init__.py,sha256=
|
|
143
|
+
verifiers/v1/runtimes/__init__.py,sha256=zOG9BYsK19VRNqvggqh3f6g1_UGIq7VjIBcK8kDlmnQ,3515
|
|
144
144
|
verifiers/v1/runtimes/apptainer.py,sha256=Ru9QJna_l0goIzg71G-4RU_D-7wZUNz139S5TEvbzog,8211
|
|
145
|
-
verifiers/v1/runtimes/base.py,sha256=
|
|
145
|
+
verifiers/v1/runtimes/base.py,sha256=2ShKZypScklVl9r0ZgN9LjdZB1e471plTxLyHg7-Tyo,19035
|
|
146
146
|
verifiers/v1/runtimes/container.py,sha256=_hNhGOo1Zvorj7THSj2UrZTkgewySUVJe4Kd777-QnI,12563
|
|
147
|
+
verifiers/v1/runtimes/e2b.py,sha256=yBeocAutsv2l5x-C5IMqrlgBz6Cb-t6vWbX4qTjlztE,30306
|
|
147
148
|
verifiers/v1/runtimes/limiters.py,sha256=rOfQJcMdYcLSKq8AL9O09BfGOLqpYmNgrCxXXNcHLmU,2849
|
|
148
149
|
verifiers/v1/runtimes/modal.py,sha256=ZHA4vtNIZwTV-bnVUlambgc8l34j_ZulvtrwvvkE2Kg,17945
|
|
149
|
-
verifiers/v1/runtimes/prime.py,sha256=
|
|
150
|
+
verifiers/v1/runtimes/prime.py,sha256=pkrHMWzBdmkNRBBelnu_fV5LO2kdEQmVlm0G2p7uZEk,19403
|
|
150
151
|
verifiers/v1/runtimes/subprocess.py,sha256=vnGVAI3J7pYmRei4MQ7MN4nb8SglLi70NtVTINMPVvQ,8855
|
|
151
|
-
verifiers/v1/runtimes/docker/__init__.py,sha256=
|
|
152
|
+
verifiers/v1/runtimes/docker/__init__.py,sha256=QoFOTMgRjoWD--O25Jsft9eD2vjcrMpJQyhfQXqWraI,21828
|
|
152
153
|
verifiers/v1/runtimes/docker/egress.py,sha256=wtKWTL2_zFidxMR7lDRmfevQlOsl2Iyk88ebdO4Kqqw,24566
|
|
153
154
|
verifiers/v1/serve/__init__.py,sha256=wOKuwfugzmDT4yIniwzCXlf4G6tv6Cg8Kc--WMa38Bs,553
|
|
154
155
|
verifiers/v1/serve/client.py,sha256=E2y2x4qqYveWRWC9VZYQEu19aMEryPWTdXnYzbq5uTA,9829
|
|
@@ -161,7 +162,7 @@ verifiers/v1/tasksets/__init__.py,sha256=2ijE3qIlLoUA02g6iysOwWQylyBBQL1GhSpjv8_
|
|
|
161
162
|
verifiers/v1/tasksets/harbor/__init__.py,sha256=JxThzHFYEkuSfQbVf3pEnjordgdfe4MU-QOsSSbyovU,326
|
|
162
163
|
verifiers/v1/tasksets/harbor/compose.py,sha256=Ab-vpMPFXy7Y7C_82-IRznrlaN3FLEuFDz8w3LDsE-I,14094
|
|
163
164
|
verifiers/v1/tasksets/harbor/env.py,sha256=-U-CeyeuWysXCNHi3cew0XtakUa7TqJVnVzElKtG41w,6356
|
|
164
|
-
verifiers/v1/tasksets/harbor/taskset.py,sha256=
|
|
165
|
+
verifiers/v1/tasksets/harbor/taskset.py,sha256=ggz26hWv9XFZAfkzp72vsrQjaXKShO4AOCzrSg2K4ds,36503
|
|
165
166
|
verifiers/v1/tasksets/harbor/toolset.py,sha256=_7HXu-uuvqzhmJ1EX6c3KWLg0g0eRrLjCq1kBsZhIwo,3159
|
|
166
167
|
verifiers/v1/tasksets/nemo_gym/__init__.py,sha256=654gAvG3Wd_Z_nh6mypeKn4s_IIQ0Ge6vcZvxTHC2_E,306
|
|
167
168
|
verifiers/v1/tasksets/nemo_gym/response.py,sha256=EhrsquEP8g0bjzAThGfE6Ypgy86_nVX235NiFYtVDVM,4463
|
|
@@ -193,8 +194,8 @@ verifiers/v1/utils/scope.py,sha256=bzUEiWDOVdbj7dXOwGOlzsQ_-Xu0j9NR5TalQB-8TS8,8
|
|
|
193
194
|
verifiers/v1/utils/score.py,sha256=493yJVMw8teCu9JxapxMFPFzI0hNUqdo0Y2nGW4kckk,6200
|
|
194
195
|
verifiers/v1/utils/trace_store.py,sha256=RsCDonGR-bs-tvEstcppiJm3jFmAdIiG83fA5KtCbAw,2801
|
|
195
196
|
verifiers/v1/utils/version.py,sha256=-obEo_-l9-D8FLef4hYxncOe-uJpxrM1g2Hig_37Sgs,1607
|
|
196
|
-
verifiers-0.3.2.
|
|
197
|
-
verifiers-0.3.2.
|
|
198
|
-
verifiers-0.3.2.
|
|
199
|
-
verifiers-0.3.2.
|
|
200
|
-
verifiers-0.3.2.
|
|
197
|
+
verifiers-0.3.2.dev169.dist-info/METADATA,sha256=gGCq6PfSW4uT2QFaSSV4A2aFBRZ-J2_jssUE0-jZdpc,4227
|
|
198
|
+
verifiers-0.3.2.dev169.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
199
|
+
verifiers-0.3.2.dev169.dist-info/entry_points.txt,sha256=iugElcdWPKbQM7uFF0lZ8iUpHsNr17-BwEAAjJWxV3U,259
|
|
200
|
+
verifiers-0.3.2.dev169.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
|
|
201
|
+
verifiers-0.3.2.dev169.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|