mirror of
https://github.com/radixark/miles.git
synced 2026-10-02 07:14:53 +08:00
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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user