fix(openenv): build E2B task templates as root (#2237)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tao Lin
2026-08-07 17:54:30 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 2e7a93ff47
commit f645c780a3
2 changed files with 82 additions and 9 deletions
@@ -55,20 +55,33 @@ from tb2_sandbox_recipe import (
)
# The user every build command and the env server run as. The TB2 task images
# are built for a root agent (their solutions and tests apt-install freely), so
# anything less would change the task environment, not just the build.
#
# The BUILD is where this is load-bearing: E2B Cloud runs template-build
# commands as a non-root user, which fails every layer of the recipe. At
# RUNTIME both endpoints already default to root (measured), so passing it to
# the server exec pins the task environment's user rather than fixing a
# failure — a provider default that changed would otherwise change what the
# agent may do, silently.
_BUILD_USER = "root"
def template_alias(task_dir: Path) -> str:
"""Deterministic template alias: ``tb2-<task-id>-<recipe digest>``.
The digest covers the base image, every build command (which embed the
tbench2_env source, deterministically tarred — see ``_dir_tar_b64``), and
the build resources (E2B sizes sandboxes at template-build time), so the
alias changes exactly when the baked artifact would: recipe edits,
env-package changes, or a task.toml resource bump re-bake; identical
inputs reuse the existing template.
The digest covers the base image, the build user, every build command (which
embed the tbench2_env source, deterministically tarred — see
``_dir_tar_b64``), and the build resources (E2B sizes sandboxes at
template-build time), so the alias changes exactly when the baked artifact
would: recipe edits, env-package changes, or a task.toml resource bump
re-bake; identical inputs reuse the existing template.
"""
task_dir = Path(task_dir)
base = resolve_docker_image(task_dir, None)
resources = task_build_resources(task_dir)
inputs = [base, *server_layer_commands(task_dir), repr(sorted(resources.items()))]
inputs = [base, _BUILD_USER, *server_layer_commands(task_dir), repr(sorted(resources.items()))]
digest = hashlib.sha256("\n".join(inputs).encode()).hexdigest()[:10]
slug = re.sub(r"[^a-z0-9-]", "-", task_dir.name.lower())
return f"tb2-{slug}-{digest}"
@@ -134,7 +147,11 @@ def ensure_task_template(
if not force and Template.alias_exists(alias, **_connection_opts()):
return alias
base = resolve_docker_image(task_dir, None)
template = Template().from_image(base)
# set_user before the first command: E2B runs template-build commands as
# a NON-root user by default, which fails every layer of the recipe
# (apt-get exits 100, /opt is not writable). A self-hosted AgentENV
# builds as root and so never showed this.
template = Template().from_image(base).set_user(_BUILD_USER)
for command in server_layer_commands(task_dir):
template = template.run_cmd(command)
@@ -229,9 +246,14 @@ def create_task_sandbox(
)
try:
cmd = server_cmd(command_timeout_s, default_task_id=task_dir.name)
# user: the env server executes the agent's commands, so the user it
# runs as IS the task environment's user. E2B defaults to a non-root
# user; a TB2 task image expects root (its own tests apt-install), so
# anything else would silently change what the agent can do.
sandbox.commands.run(
f"bash -c {shlex.quote(cmd)} > /tmp/openenv-server.log 2>&1",
background=True,
user=_BUILD_USER,
)
url = base_url(sandbox)
wait_server_ready(url, timeout_s=ready_timeout_s)
@@ -71,13 +71,20 @@ class _FakeTemplateCls:
def __init__(self):
self.commands = []
self.steps = [] # ordered build steps, to prove set_user precedes them
def from_image(self, base):
self.base = base
return self
def set_user(self, user):
self.user = user
self.steps.append(("set_user", user))
return self
def run_cmd(self, cmd):
self.commands.append(cmd)
self.steps.append(("run_cmd", cmd))
return self
@staticmethod
@@ -120,6 +127,28 @@ def test_ensure_template_builds_with_recipe_and_resources(monkeypatch, fake_e2b)
assert kwargs["api_key"].startswith("e2b_")
def test_ensure_template_builds_as_root(monkeypatch, fake_e2b):
"""E2B runs build commands as a NON-root user by default, which fails every
layer of the recipe (apt-get exits 100, /opt is not writable) — observed on
E2B Cloud, invisible on a self-hosted AgentENV that builds as root. The user
must be set BEFORE the first command, since set_user only affects what
follows it."""
_patch_recipe(monkeypatch, commands=("apt-get install -y curl", "mkdir -p /opt/envserver"))
sandbox.ensure_task_template(Path("/tasks/t"))
((template, _, _),) = _FakeTemplateCls.builds
assert template.user == "root"
assert template.steps[0] == ("set_user", "root")
def test_template_alias_tracks_the_build_user(monkeypatch, fake_e2b):
"""The user is part of the baked artifact, so changing it must re-bake
rather than serve a template built by whoever built it first."""
_patch_recipe(monkeypatch)
before = sandbox.template_alias(Path("/tasks/t"))
monkeypatch.setattr(sandbox, "_BUILD_USER", "someone-else")
assert sandbox.template_alias(Path("/tasks/t")) != before
def test_ensure_template_force_rebuilds_skipping_cache(monkeypatch, fake_e2b):
_patch_recipe(monkeypatch)
_FakeTemplateCls.exists = True # force must rebuild anyway
@@ -143,7 +172,8 @@ def test_ensure_template_enforces_build_wall_clock(monkeypatch, fake_e2b):
class _FakeSandbox:
def __init__(self):
self.killed = False
self.commands = type("C", (), {"run": staticmethod(lambda *a, **k: None)})()
self.ran = []
self.commands = type("C", (), {"run": lambda _self, *a, **k: self.ran.append((a, k))})()
def get_host(self, port):
return f"{port}-id.example.test"
@@ -169,6 +199,27 @@ def test_create_task_sandbox_kills_on_partial_failure(monkeypatch, fake_e2b):
assert created.killed
def test_create_runs_the_env_server_as_root(monkeypatch, fake_e2b):
"""The env server executes the agent's commands, so the user it runs as IS
the task environment's user. E2B defaults to a non-root user; a TB2 task
image expects root (its own tests apt-install), so the server must be
started as root or the agent silently loses privileges it should have."""
created = _FakeSandbox()
fake_e2b.Sandbox = type("S", (), {"create": staticmethod(lambda **k: created)})
monkeypatch.setattr(sandbox, "ensure_task_template", lambda task_dir: "tb2-t-abc")
monkeypatch.setattr(sandbox, "sandbox_labels", lambda task_dir: {})
monkeypatch.setattr(sandbox, "server_cmd", lambda *a, **k: "serve")
monkeypatch.setattr(sandbox, "wait_server_ready", lambda url, timeout_s: None)
monkeypatch.setattr(sandbox, "_start_keepalive", lambda sb, task_id: None)
sandbox.create_task_sandbox(Path("/tasks/t"))
((args, kwargs),) = created.ran
assert kwargs["user"] == "root"
assert kwargs["background"] is True
assert "serve" in args[0]
def test_build_lock_is_per_alias():
lock_a1 = sandbox._build_lock("tb2-a-123")
lock_a2 = sandbox._build_lock("tb2-a-123")