Spaces:
Running
Running
| """Sandbox management — fail-closed E2B or bubblewrap isolation.""" | |
| from __future__ import annotations | |
| import codecs | |
| import collections | |
| import contextlib | |
| import contextvars | |
| import functools | |
| import io | |
| import logging | |
| import math | |
| import os | |
| import select | |
| import selectors | |
| import shlex | |
| import shutil | |
| import signal | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| import weakref | |
| from collections.abc import Callable, Mapping, Sequence | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, NamedTuple | |
| from frontier_agent.infra.usage_meter import ( | |
| close_meter_span, | |
| open_meter_span, | |
| record_api_request, | |
| set_meter_gauge, | |
| ) | |
| from plugins.tools._exec_cgroup import ( | |
| MEM_MAX_ENV as _EXEC_CG_MEM_MAX_ENV, | |
| ) | |
| from plugins.tools._exec_cgroup import ( | |
| ExecCgroup, | |
| create_exec_cgroup, | |
| ) | |
| from plugins.tools._path_auth import _path_within | |
| logger = logging.getLogger(__name__) | |
| _sandbox = None # E2B Sandbox | BwrapSandbox | task override | None | |
| # Serializes shared-singleton creation. Without it, a burst of concurrent | |
| # sub-agents all see ``_sandbox is None``, each call ``Sandbox.create()``, and | |
| # overwrite the global — spinning up (and leaking) N sandboxes instead of one. | |
| _sandbox_lock = threading.Lock() | |
| # ── Per-task sandbox override ────────────────────────────────────────── | |
| # Used by SWE benchmark for per-task E2B isolation. When set, get_sandbox() | |
| # returns this instead of the shared singleton. | |
| _task_sandbox: contextvars.ContextVar[Any] = contextvars.ContextVar( | |
| "_task_sandbox", default=None, | |
| ) | |
| def set_task_sandbox(sandbox: Any) -> contextvars.Token: | |
| """Set a per-task sandbox for the current async context. | |
| Returns a token that must be passed to clear_task_sandbox(). | |
| """ | |
| return _task_sandbox.set(sandbox) | |
| def clear_task_sandbox(token: contextvars.Token) -> None: | |
| """Restore the previous sandbox context.""" | |
| _task_sandbox.reset(token) | |
| # ── Shared result/error types ────────────────────────────────────────── | |
| class _CommandResult: | |
| """Result of a sandbox command execution.""" | |
| stdout: str = "" | |
| stderr: str = "" | |
| exit_code: int = 0 | |
| class SandboxError(RuntimeError): | |
| """Base class for sandbox configuration and availability failures.""" | |
| class SandboxConfigurationError(SandboxError): | |
| """The configured isolation backend is invalid or incomplete.""" | |
| class SandboxUnavailableError(SandboxError): | |
| """The configured isolation backend cannot be created.""" | |
| class SandboxCapacityError(SandboxUnavailableError): | |
| """The E2B pool cannot provide an isolated sandbox in time.""" | |
| # Thread-pool caps for native math libs. Without these, every concurrent | |
| # local bwrap exec spins up one BLAS/OpenMP thread *per core* — with dozens | |
| # of sub-agents running code at once that oversubscribes CPU and | |
| # inflates memory. Pinning to 1 keeps aggregate footprint predictable; heavy | |
| # parallelism comes from running many agents, not many threads | |
| # per exec. | |
| _THREAD_CAP_ENV = { | |
| "OMP_NUM_THREADS": "1", | |
| "OPENBLAS_NUM_THREADS": "1", | |
| "MKL_NUM_THREADS": "1", | |
| "NUMEXPR_NUM_THREADS": "1", | |
| "VECLIB_MAXIMUM_THREADS": "1", | |
| } | |
| # ── Minimal environment for model-authored subprocesses ─────────────── | |
| # A denylist is not a security boundary: credentials also arrive under names | |
| # such as DATABASE_URL / *_DSN, while Kubernetes injects service topology, | |
| # pod identity, and internal hostnames under entirely non-secret-looking keys. | |
| # Build the child environment from zero instead. The harness process keeps its | |
| # full environment for LLM/search clients; only model-authored bash/python sees | |
| # this allowlisted projection. | |
| _TOOL_ENV_ALLOWLIST: frozenset[str] = frozenset({ | |
| "PATH", | |
| "LANG", | |
| "LANGUAGE", | |
| "LC_ALL", | |
| "LC_CTYPE", | |
| "TERM", | |
| "TZ", | |
| # Runtime/toolchain configuration that contains paths, never credentials. | |
| # Some vendor images install LibreOffice beside private shared libraries, | |
| # so its launcher needs the image-provided dynamic-library search path. | |
| "LD_LIBRARY_PATH", | |
| # Set by the images to a baked cache. Without it tiktoken re-downloads its | |
| # BPE files on every in-sandbox call — the old substring denylist ate this | |
| # name too (it contains "TOKEN"), so it has been silently missing for a while. | |
| "TIKTOKEN_CACHE_DIR", | |
| # Global document-generation modules baked into the serving images. | |
| "NODE_PATH", | |
| "MATPLOTLIBRC", | |
| "MPLCONFIGDIR", | |
| "FONTCONFIG_FILE", | |
| "FONTCONFIG_PATH", | |
| "SSL_CERT_FILE", | |
| "SSL_CERT_DIR", | |
| "REQUESTS_CA_BUNDLE", | |
| "CURL_CA_BUNDLE", | |
| # Non-secret runtime root for the AmberTools CLI copied into the unified | |
| # react/stateful image. antechamber uses it to locate its data files. | |
| "AMBERHOME", | |
| # Egress proxy. Without these, every outbound call a model command makes on | |
| # a proxied deployment fails — including the ``pip install`` that PIP_TARGET | |
| # above exists to keep working. Both cases are listed because most tools | |
| # read only one of them. | |
| # | |
| # Caveat for deployments: a proxy URL of the form | |
| # ``http://user:pass@proxy`` puts those credentials into the model's | |
| # environment. Stripping the userinfo here would just break authenticated | |
| # proxies, so prefer a credential-free proxy URL (or a transparent/PAC | |
| # proxy) wherever model commands run. | |
| "HTTP_PROXY", | |
| "HTTPS_PROXY", | |
| "NO_PROXY", | |
| "http_proxy", | |
| "https_proxy", | |
| "no_proxy", | |
| }) | |
| _TOOL_ENV_EXTRA_ALLOWLIST = "FRONTIER_AGENT_TOOL_ENV_ALLOWLIST" | |
| # ── Unprivileged identity for model-authored subprocesses ───────────── | |
| # Clearing the child's own environment is necessary but not sufficient: in | |
| # container mode the model's bash/python shares the harness's PID namespace, so | |
| # ``cat /proc/<harness-pid>/environ`` reads the real provider credentials | |
| # straight out of the parent. That file is ``-r--------`` and additionally | |
| # gated by ptrace_may_access, so the boundary is simply *not being the same | |
| # uid*. The task container runs as root, which means dropping to a dedicated | |
| # unprivileged uid needs no capability, no namespace, and no runtime policy | |
| # change — unlike bubblewrap, which the production runtime does not permit. | |
| # ── Sandbox profile: who are we protecting from whom ────────────────────── | |
| # | |
| # ``local`` (default): one user, their own machine, their own keys. The jail is | |
| # a *filesystem* boundary — the working directory read-write, the system | |
| # read-only, and $HOME absent, so a mistaken command cannot reach ~/.ssh or | |
| # delete an unrelated project. | |
| # | |
| # ``service``: a shared worker running someone else's task. Now the harness | |
| # process itself holds credentials that the task must not read, so the jail | |
| # additionally needs a PID namespace with its own procfs. That costs privileges | |
| # many runtimes will not grant, which is the right trade for a service and the | |
| # wrong one for a laptop. | |
| # | |
| # Set ``SANDBOX_PROFILE=service`` when deploying this as a multi-tenant backend. | |
| _SANDBOX_PROFILE_ENV = "SANDBOX_PROFILE" | |
| _PROFILE_LOCAL = "local" | |
| _PROFILE_SERVICE = "service" | |
| def _sandbox_profile() -> str: | |
| """``local`` (default) or ``service`` — see the note above.""" | |
| raw = (os.environ.get(_SANDBOX_PROFILE_ENV) or "").strip().lower() | |
| return _PROFILE_SERVICE if raw == _PROFILE_SERVICE else _PROFILE_LOCAL | |
| _TOOL_USER_ENV = "FRONTIER_AGENT_TOOL_USER" | |
| _DEFAULT_TOOL_USER = "agent-tool" | |
| _REQUIRE_TOOL_USER_ENV = "FRONTIER_AGENT_REQUIRE_TOOL_USER" | |
| # Model-installed packages: /opt/venv is root-owned and must stay that way (a | |
| # writable venv lets the model plant code the harness itself later imports), and | |
| # pip refuses ``--user`` inside a virtualenv. | |
| # | |
| # The images therefore bake an OVERLAY VENV owned by the tool user | |
| # (``_DEFAULT_TOOL_VENV``): its site-packages is writable, and a ``.pth`` there | |
| # puts the baked venv's site-packages on ``sys.path``. With its ``bin`` first on | |
| # PATH the model gets a ``python3``/``pip`` pair that sees every baked package as | |
| # INSTALLED, so ``pip install matplotlib`` answers "already satisfied" and only | |
| # genuinely-missing packages are fetched — into the overlay, where they import. | |
| # | |
| # The previous approach (``PIP_TARGET`` + ``PYTHONPATH`` into the tool user's | |
| # HOME) kept installs working but re-downloaded what the image already had: | |
| # ``PIP_TARGET`` is ``pip install --target``, which resolves against the target | |
| # directory alone and ignores distributions installed elsewhere on sys.path — | |
| # and that directory is empty in every fresh task container. On an image whose | |
| # venv held both, ``pip install matplotlib python-pptx`` measured 45 downloads / | |
| # 181 MB with 0 "already satisfied", and the fetched copies then SHADOWED the | |
| # baked, self-tested versions via PYTHONPATH. It remains the fallback below for | |
| # images built before the overlay existed — degraded, never fatal, matching how | |
| # the tool-user drop itself rolls out. | |
| # | |
| # What the tool user may write there is the WHOLE overlay, ``bin/`` included: | |
| # pip has to land console scripts, so narrowing it would break the installs this | |
| # exists for. The overlay is therefore container-lifetime state the model can | |
| # shape (replace ``bin/pip``, drop a ``sitecustomize.py``) where the PIP_TARGET | |
| # directory was per-task HOME. No privilege gain — same uid, and the harness | |
| # imports only /opt/venv — but if a container ever serves more than one | |
| # request, that state carries across them. | |
| _TOOL_SITE_SUBDIR = ".local/site-packages" | |
| _TOOL_VENV_ENV = "FRONTIER_AGENT_TOOL_VENV" | |
| _DEFAULT_TOOL_VENV = "/opt/tool-venv" | |
| # Read-only runtime toolchains outside the standard /usr tree. The unified | |
| # react/stateful image places AmberTools under /opt/chem; container mode sees | |
| # it directly, while bwrap must bind it explicitly or `antechamber` vanishes. | |
| # Deployments can add more colon-separated roots without changing the sandbox | |
| # code. Only existing absolute directories are accepted. | |
| _EXTRA_TOOL_ROOTS_ENV = "FRONTIER_AGENT_EXTRA_TOOL_ROOTS" | |
| _DEFAULT_EXTRA_TOOL_ROOTS = "/opt/chem" | |
| class _ToolIdentity(NamedTuple): | |
| name: str | |
| uid: int | |
| gid: int | |
| def _tool_identity_for(name: str, euid: int) -> _ToolIdentity | None: | |
| """Resolve *name* to a uid/gid usable for dropping privileges. | |
| Returns ``None`` (with a warning) when privilege separation cannot apply: | |
| a non-root harness cannot ``setuid``, and an image without the account | |
| cannot host it. Callers must treat that as degraded, never as fatal — | |
| failing closed here would take down every task on a runtime that simply | |
| has not rolled out the new base image yet. | |
| """ | |
| if not name or name.lower() in {"off", "none", "disabled"}: | |
| return None | |
| if os.name != "posix": | |
| return None | |
| if euid != 0: | |
| logger.warning( | |
| "Tool-user isolation inactive: harness euid=%s cannot setuid to %r; " | |
| "model commands share the harness uid and can read " | |
| "/proc/<pid>/environ", euid, name, | |
| ) | |
| return None | |
| try: | |
| import pwd | |
| entry = pwd.getpwnam(name) | |
| except KeyError: | |
| logger.error( | |
| "Tool-user isolation inactive: account %r missing from the image; " | |
| "model commands run as root and can read the harness credentials " | |
| "from /proc/<pid>/environ. Add it (useradd -r %s) or set %s=off " | |
| "to acknowledge.", name, name, _TOOL_USER_ENV, | |
| ) | |
| return None | |
| except Exception as exc: # pragma: no cover - platform specific | |
| logger.warning("Tool-user lookup failed for %r: %s", name, exc) | |
| return None | |
| if entry.pw_uid == 0: | |
| logger.error( | |
| "Tool-user %r resolves to uid 0; refusing to treat it as a " | |
| "privilege boundary", name, | |
| ) | |
| return None | |
| return _ToolIdentity(name, entry.pw_uid, entry.pw_gid) | |
| def tool_identity_or_none() -> _ToolIdentity | None: | |
| """The unprivileged identity model commands run as, or ``None``. | |
| Never raises — for best-effort callers (permission prep) that must not turn | |
| a missing tool account into a failed task. | |
| """ | |
| name = (os.environ.get(_TOOL_USER_ENV) or _DEFAULT_TOOL_USER).strip() | |
| return _tool_identity_for(name, os.geteuid() if os.name == "posix" else -1) | |
| def tool_identity() -> _ToolIdentity | None: | |
| """As :func:`tool_identity_or_none`, honouring the opt-in strict switch.""" | |
| identity = tool_identity_or_none() | |
| if identity is None and _require_tool_user(): | |
| name = (os.environ.get(_TOOL_USER_ENV) or _DEFAULT_TOOL_USER).strip() | |
| raise SandboxUnavailableError( | |
| f"{_REQUIRE_TOOL_USER_ENV} is set but the unprivileged tool user " | |
| f"{name!r} is unavailable; model commands would run with the " | |
| "harness's own uid and could read its credentials from " | |
| "/proc/<pid>/environ" | |
| ) | |
| return identity | |
| def _require_tool_user() -> bool: | |
| return ( | |
| os.environ.get(_REQUIRE_TOOL_USER_ENV, "").strip().lower() | |
| in {"1", "true", "yes", "on"} | |
| ) | |
| def _tool_overlay_venv(env: Mapping[str, str]) -> str | None: | |
| """Path of the tool user's writable overlay venv, or ``None`` if absent. | |
| ``FRONTIER_AGENT_TOOL_VENV=off`` forces the ``PIP_TARGET`` fallback; any other | |
| value names the venv to use. Probed by interpreter presence, not directory | |
| existence, so a half-built path can never shadow a working PATH. | |
| """ | |
| raw = (env.get(_TOOL_VENV_ENV) or _DEFAULT_TOOL_VENV).strip() | |
| if not raw or raw.lower() in {"off", "none", "disabled"}: | |
| return None | |
| return raw if Path(raw, "bin", "python3").exists() else None | |
| def _tool_env_allowlist(env: dict[str, str]) -> frozenset[str]: | |
| """Return the built-in allowlist plus trusted, exact-name extensions. | |
| The extension is deployment configuration, not a glob or substring match. | |
| Its own control variable is never copied into the child environment. | |
| """ | |
| extra = env.get(_TOOL_ENV_EXTRA_ALLOWLIST, "") | |
| names = { | |
| item.strip() | |
| for item in extra.split(",") | |
| if item.strip() and item.strip() != _TOOL_ENV_EXTRA_ALLOWLIST | |
| } | |
| return _TOOL_ENV_ALLOWLIST | frozenset(names) | |
| def _build_tool_env( | |
| env: dict[str, str], | |
| *, | |
| home: str, | |
| env_allow: tuple[str, ...] = (), | |
| overlay_writable: bool = True, | |
| tmpdir: str = "/tmp", | |
| ) -> dict[str, str]: | |
| """Construct a minimal environment for model-authored subprocesses. | |
| This function deliberately does not preserve unknown keys. Tools requiring | |
| an additional non-secret variable must opt in by exact name through | |
| ``FRONTIER_AGENT_TOOL_ENV_ALLOWLIST`` or pass it explicitly for that call. | |
| ``env_allow`` is the per-call counterpart: trusted harness callers may | |
| expose exact name prefixes without granting them to every model-authored | |
| command sharing the sandbox. Never pass model-controlled values. | |
| ``overlay_writable=False`` says the caller mounts the overlay venv read-only | |
| (bwrap does — see ``_bwrap_base_args``). The overlay still leads PATH, so | |
| ``python3`` is the same interpreter in both backends, but pip is pointed at | |
| the workspace HOME instead of a filesystem it cannot write. | |
| """ | |
| allowed = _tool_env_allowlist(env) | |
| opt_in = tuple(prefix.upper() for prefix in env_allow) | |
| projected = { | |
| key: value | |
| for key, value in env.items() | |
| if key in allowed or (opt_in and key.upper().startswith(opt_in)) | |
| } | |
| projected["HOME"] = home | |
| # Do not inherit the harness's TMPDIR: it may name a root-only directory | |
| # that the unprivileged tool user cannot access. | |
| # | |
| # Callers that own a private scratch root pass it here. That redirects | |
| # everything which ASKS the platform for a temp path — ``tempfile``, | |
| # ``mktemp``, most libraries' spill files — into per-agent storage. It does | |
| # NOT redirect a literal ``/tmp/foo`` written by model code: without a | |
| # mount namespace nothing can, because ``/tmp`` is then genuinely one | |
| # directory (see ``_container_inner_bwrap_enabled`` — production container | |
| # mode has no inner namespace). Measured on 4 concurrent sub-agents writing | |
| # ``/tmp/scratch.csv``: all four read back the last writer's content. | |
| projected["TMPDIR"] = tmpdir | |
| # Keep pip/matplotlib/caches inside a HOME the tool user owns. Without | |
| # these they target root-owned paths and fail with bare permission errors | |
| # the model cannot act on. | |
| projected["PIP_CACHE_DIR"] = f"{home}/.cache/pip" | |
| overlay = _tool_overlay_venv(env) | |
| site = f"{home}/{_TOOL_SITE_SUBDIR}" | |
| if overlay: | |
| # Overlay leads PATH so `python3` is the same interpreter under both | |
| # backends, and its python3/pip see the baked stack through the .pth. | |
| projected["VIRTUAL_ENV"] = overlay | |
| projected["PATH"] = f"{overlay}/bin:{home}/.local/bin:" + projected.get("PATH", "") | |
| if not overlay_writable: | |
| # Read-only mount (bwrap): pip must not try to write the overlay, or | |
| # a genuinely-missing package fails with `[Errno 30] Read-only file | |
| # system` — an "environment looks broken" error of exactly the kind | |
| # that sends a model thrashing. Fall back to the workspace HOME, | |
| # which bwrap binds read-write. Accepted cost, eval/local only: | |
| # --target resolves against that directory alone, so a baked package | |
| # is re-fetched there, and PYTHONPATH lets the copy shadow it. | |
| projected["PIP_TARGET"] = site | |
| projected["PYTHONPATH"] = site | |
| else: | |
| projected["PIP_TARGET"] = site | |
| projected["PYTHONPATH"] = site | |
| projected["PATH"] = f"{site}/bin:{home}/.local/bin:" + projected.get("PATH", "") | |
| # setdefault, not assignment: a trusted caller passing an ``env_allow`` | |
| # prefix that covers these (e.g. ``MPL``) means to supply its own. | |
| projected.setdefault("MPLCONFIGDIR", f"{home}/.cache/matplotlib") | |
| # Same reason as MPLCONFIGDIR, for the two other libraries that write a | |
| # cache on import. Numba is the load-bearing one: it caches compiled | |
| # kernels NEXT TO the module by default, and the tool user cannot write | |
| # /opt/venv, so ``import scanpy`` dies with "cannot cache function | |
| # 'agg_sum_csr-parallel': no locator available" — a failure a model reads as | |
| # a broken package and answers with a reinstall. Caught by the agent-team | |
| # build gate, which runs as the tool user with no writable HOME. | |
| projected.setdefault("NUMBA_CACHE_DIR", f"{home}/.cache/numba") | |
| projected.setdefault("XDG_CACHE_HOME", f"{home}/.cache") | |
| projected.update(_THREAD_CAP_ENV) | |
| return projected | |
| def _mem_limit_mb(field: str, env: str, default: int) -> int: | |
| """Per-exec virtual-memory cap (MB), read from a ``get_config()`` field with | |
| an env-var then literal-default fallback. 0 = unlimited. Shared by the | |
| local / E2B / bwrap caps so the config-with-fallback logic lives in one place. | |
| """ | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| return int(getattr(get_config(), field)) | |
| except Exception: | |
| try: | |
| return int(os.environ.get(env, str(default))) | |
| except ValueError: | |
| return default | |
| def _ulimit_cap(mem_mb: int) -> str: | |
| """Shell prefix capping virtual memory via ``ulimit -v`` (a shell builtin, | |
| thread-safe unlike ``preexec_fn``) so a runaway allocation aborts with | |
| MemoryError instead of OOM-killing the worker. Empty string when the cap is | |
| disabled (``mem_mb <= 0``).""" | |
| if mem_mb <= 0: | |
| return "" | |
| return f"ulimit -v {mem_mb * 1024} 2>/dev/null; " | |
| def _data_ulimit_cap(mem_mb: int) -> str: | |
| """Shell prefix capping the DATA segment via ``ulimit -d`` (RLIMIT_DATA). | |
| Sibling of :func:`_ulimit_cap`, and the right tool when the sandbox IS the | |
| worker container (``CurrentSandbox``), where ``ulimit -v`` is too blunt: | |
| * ``-v`` caps virtual address space, and this image ships runtimes whose | |
| VSZ:RSS ratio is large by design. A JVM (LibreOffice needs one for some | |
| conversions) reserves its whole max heap plus metaspace up front and | |
| cannot even initialise under a 2GB ``-v``; measured in this image, ``-d`` | |
| at the same number leaves it working. | |
| * Since Linux 4.7 RLIMIT_DATA covers brk *and* private anonymous mmap, so | |
| it tracks what a runaway actually consumes — an unbounded Python | |
| container growing without limit — while ignoring file mappings. | |
| Enforced by the kernel at allocation time, so unlike a polling watchdog it | |
| cannot lose a race against a single huge allocation. It is per-PROCESS, not | |
| per-process-tree: a command that forks N hungry children can still exceed | |
| the container's cgroup limit. Empty string when disabled (``mem_mb <= 0``). | |
| """ | |
| if mem_mb <= 0: | |
| return "" | |
| return f"ulimit -d {mem_mb * 1024} 2>/dev/null; " | |
| def _e2b_mem_limit_mb() -> int: | |
| """Per-exec virtual-memory cap (MB) for remote (E2B/Docker) sandboxes. | |
| 0 = disabled. See ``sandbox_e2b_mem_mb`` in config for the sizing story.""" | |
| return _mem_limit_mb("sandbox_e2b_mem_mb", "SANDBOX_E2B_MEM_MB", 896) | |
| _DATA_CAP_EFFECTIVE: bool | None = None | |
| _DATA_CAP_LOCK = threading.Lock() | |
| # Cap used by the data_cap_effective() probe. Deliberately roomy: see the | |
| # comment in that function for why a tight probe is actively wrong. | |
| _PROBE_MB = 1024 | |
| # ── live exec registry (memory watchdog disposal) ────────────────────── | |
| # | |
| # Every in-flight model-authored exec, keyed by process-group id. The memory | |
| # watchdog needs a way to shed load before the kernel does, and "walk /proc and | |
| # kill the biggest thing" is not it: that also finds MCP stdio servers and the | |
| # harness's own children. Only what this backend started is eligible. | |
| _LIVE_EXECS: dict[int, dict[str, Any]] = {} | |
| _LIVE_EXECS_LOCK = threading.Lock() | |
| def _register_exec( | |
| pgid: int, command: str, cgroup: ExecCgroup | None = None, | |
| ) -> None: | |
| with _LIVE_EXECS_LOCK: | |
| _LIVE_EXECS[pgid] = { | |
| "command": command, | |
| "killed_by_guard": False, | |
| "cgroup": cgroup, | |
| } | |
| def _killed_by_guard(pgid: int) -> bool: | |
| """Whether the watchdog killed *pgid*. Read-only; unregistration is the | |
| caller's ``finally``, so this stays correct on every exit path.""" | |
| with _LIVE_EXECS_LOCK: | |
| entry = _LIVE_EXECS.get(pgid) | |
| return bool(entry and entry["killed_by_guard"]) | |
| def _unregister_exec(pgid: int) -> None: | |
| with _LIVE_EXECS_LOCK: | |
| _LIVE_EXECS.pop(pgid, None) | |
| def _exec_mem_kb(pgid: int, meta: dict[str, Any]) -> int: | |
| """Memory charged to one live exec, in KiB. 0 when unknown. | |
| Prefers the exec cgroup's ``memory.current`` when the exec has one: a | |
| single read that covers the WHOLE tree (including ``setsid`` escapees a | |
| pgid walk misses) instead of a full /proc scan per candidate — and the | |
| watchdog calls this every 3 seconds. | |
| """ | |
| cgroup: ExecCgroup | None = meta.get("cgroup") | |
| if cgroup is not None: | |
| try: | |
| with open(cgroup.current_path) as fh: | |
| return int(fh.read()) // 1024 | |
| except (OSError, ValueError): | |
| pass # already rmdir'd or unreadable — fall back to /proc | |
| return _pgid_rss_kb(pgid) | |
| def _pgid_rss_kb(pgid: int) -> int: | |
| """Summed RSS of every live process in *pgid*, from /proc. 0 when unknown.""" | |
| total = 0 | |
| try: | |
| for pid_dir in os.listdir("/proc"): | |
| if not pid_dir.isdigit(): | |
| continue | |
| try: | |
| with open(f"/proc/{pid_dir}/stat", "rb") as fh: | |
| fields = fh.read().split() | |
| # After the (comm) field: state ppid pgrp ... comm may contain | |
| # spaces and parens, so index from the LAST ')' instead of | |
| # splitting blindly — the classic /proc/stat parsing trap. | |
| raw = b" ".join(fields) | |
| tail = raw[raw.rindex(b")") + 2:].split() | |
| if int(tail[2]) != pgid: # tail[2] = pgrp | |
| continue | |
| with open(f"/proc/{pid_dir}/statm", "rb") as fh: | |
| total += int(fh.read().split()[1]) * (os.sysconf("SC_PAGE_SIZE") // 1024) | |
| except (OSError, ValueError, IndexError): | |
| continue | |
| except OSError: | |
| return 0 | |
| return total | |
| _COMMAND_DIGEST_CHARS = 240 | |
| # Grace given to the output pumps AFTER the process has exited. Independent of | |
| # the command timeout — see _stream_capped. | |
| _POST_EXIT_DRAIN_S = 5.0 | |
| def _command_digest(command: str) -> str: | |
| """Shorten a command for a log line, keeping the END rather than the start. | |
| Callers prepend boilerplate before we ever see the string — bash.py alone | |
| contributes the two-branch ``ulimit -f`` prefix and a net-guard ``export`` — | |
| so the head is ~200 characters of scaffolding and the actual command is last. | |
| Truncating from the front produced kill lines whose entire payload was the | |
| prefix, i.e. it failed at the one thing recording the command is for. Found | |
| by reading a real kill line in an end-to-end run, not by unit tests. | |
| """ | |
| command = command.strip() | |
| if len(command) <= _COMMAND_DIGEST_CHARS: | |
| return command | |
| return "…" + command[-_COMMAND_DIGEST_CHARS:] | |
| def kill_heaviest_exec() -> dict[str, Any] | None: | |
| """SIGKILL the largest in-flight exec process group. Best effort. | |
| The memory watchdog's disposal step. Returns what was killed (for the trace | |
| event and the log line) or None when there is nothing eligible — in which | |
| case the pressure is the harness's own and killing an exec would not help. | |
| Deliberately best effort, NOT a guarantee: sampling loses to a single huge | |
| allocation, and RLIMIT_DATA (which is a kernel-enforced guarantee) already | |
| covers the single-runaway-process case. This exists for the case RLIMIT_DATA | |
| structurally cannot cover — several concurrent execs each individually under | |
| their per-process cap, together over the container's. | |
| """ | |
| with _LIVE_EXECS_LOCK: | |
| candidates = list(_LIVE_EXECS.items()) | |
| if not candidates: | |
| return None | |
| ranked = sorted( | |
| ((pgid, meta, _exec_mem_kb(pgid, meta)) for pgid, meta in candidates), | |
| key=lambda t: t[2], reverse=True, | |
| ) | |
| pgid, meta, rss_kb = ranked[0] | |
| if rss_kb <= 0: | |
| return None | |
| with _LIVE_EXECS_LOCK: | |
| entry = _LIVE_EXECS.get(pgid) | |
| if entry is None: # finished while we were ranking | |
| return None | |
| entry["killed_by_guard"] = True | |
| with contextlib.suppress(ProcessLookupError, PermissionError, OSError): | |
| os.killpg(pgid, signal.SIGKILL) | |
| # Also kill through the exec cgroup when there is one: killpg cannot reach | |
| # a descendant that left the process group, cgroup.kill can. Taken from | |
| # the re-fetched ENTRY, not the ranking snapshot: if the pgid was reused | |
| # between the two, the snapshot's cgroup belongs to the finished exec. | |
| cgroup: ExecCgroup | None = entry.get("cgroup") | |
| if cgroup is not None: | |
| cgroup.kill() | |
| return { | |
| "pgid": pgid, | |
| "rss_mb": rss_kb // 1024, | |
| "command": _command_digest(meta["command"]), | |
| "live_execs": len(candidates), | |
| } | |
| def _output_cap_bytes() -> int: | |
| """Per-stream retention cap for captured command output. 0 = unbounded.""" | |
| return _mem_limit_mb("sandbox_output_cap_kb", "SANDBOX_OUTPUT_CAP_KB", 8192) * 1024 | |
| class _CappedSink: | |
| """Retains at most ``cap`` bytes of a stream as head + tail, counting the rest. | |
| Two properties matter and both are load bearing: | |
| * **Bounded memory.** ``Popen.communicate`` buffers the WHOLE stream before | |
| anyone can truncate it, so a command emitting gigabytes (``cat`` of a big | |
| file, ``yes |``) grows the harness heap until the kernel intervenes. The | |
| per-tool caps that exist downstream (``maybe_overflow``, and the loop's | |
| 150k-char ceiling) act on a string that has *already* been materialised — | |
| the truncation is on the wrong side of the pipe. | |
| * **Head AND tail.** Errors surface at the start (a traceback, ``command not | |
| found``) and results at the end. Keeping only the head — the obvious | |
| implementation — throws away exactly the part several tools parse, e.g. a | |
| success marker printed last. | |
| The caller must keep READING after the cap is reached; see the drain note in | |
| ``_CurrentCommands.run``. This class only stops *retaining*. | |
| """ | |
| __slots__ = ("_cap", "_head", "_head_len", "_tail", "_tail_len", "total") | |
| def __init__(self, cap: int) -> None: | |
| self._cap = cap | |
| self._head: list[str] = [] | |
| self._head_len = 0 | |
| # Tail is a bounded deque of recent chunks, trimmed from the left. | |
| self._tail: collections.deque[str] = collections.deque() | |
| self._tail_len = 0 | |
| self.total = 0 | |
| def feed(self, chunk: str) -> None: | |
| self.total += len(chunk) | |
| if self._cap <= 0: | |
| self._head.append(chunk) | |
| return | |
| half = max(1, self._cap // 2) | |
| if self._head_len < half: | |
| room = half - self._head_len | |
| self._head.append(chunk[:room]) | |
| self._head_len += min(room, len(chunk)) | |
| chunk = chunk[room:] | |
| if not chunk: | |
| return | |
| self._tail.append(chunk) | |
| self._tail_len += len(chunk) | |
| while self._tail_len > half and self._tail: | |
| oldest = self._tail[0] | |
| if self._tail_len - len(oldest) >= half: | |
| self._tail.popleft() | |
| self._tail_len -= len(oldest) | |
| else: | |
| cut = self._tail_len - half | |
| self._tail[0] = oldest[cut:] | |
| self._tail_len -= cut | |
| break | |
| def value(self) -> str: | |
| head, tail = "".join(self._head), "".join(self._tail) | |
| elided = self.total - len(head) - len(tail) | |
| if elided <= 0: | |
| return head + tail | |
| return f"{head}\n... [{elided} characters elided by the output cap] ...\n{tail}" | |
| def data_cap_effective() -> bool: | |
| """Whether ``ulimit -d`` actually takes effect here. Probed once, cached. | |
| The ``2>/dev/null`` in the cap prefix is deliberate — a shell complaining | |
| about a limit it will not lower must not pour stderr into the model's tool | |
| output — but it also means a platform that ignores ``ulimit -d`` leaves us | |
| with a safety control that silently does nothing. macOS is exactly that | |
| platform: the limit reads back as RLIM_INFINITY. Production workers are | |
| Linux, so this is a local-development trap rather than a production one, and | |
| one loud line at first exec is cheaper than discovering it during an | |
| incident. Mirrors :func:`bwrap_available`'s probe-once-and-warn shape. | |
| """ | |
| global _DATA_CAP_EFFECTIVE | |
| if _DATA_CAP_EFFECTIVE is not None: | |
| return _DATA_CAP_EFFECTIVE | |
| # Locked, result published LAST — same race shape as exec_cgroup_root(): | |
| # pre-setting the cache before a probe that releases the GIL (here for up | |
| # to 15s of subprocess) hands every concurrent caller a hardcoded False. | |
| # Today's only production caller discards the return value, but the | |
| # contract is a bool for decisions, and a future caller gating on it | |
| # would inherit the hole. | |
| with _DATA_CAP_LOCK: | |
| if _DATA_CAP_EFFECTIVE is not None: | |
| return _DATA_CAP_EFFECTIVE | |
| effective = False | |
| try: | |
| # Probe with a GENEROUS cap. The question is only "does setting | |
| # RLIMIT_DATA take effect", and a tight probe answers a different, | |
| # harmful question: it can kill the probe process itself and report a | |
| # false negative. Observed with a 64MB probe under Rosetta x86-64 | |
| # emulation, where the translation layer's own mmap failed | |
| # ("rosetta error: mmap_anonymous_rw mmap failed") before python even | |
| # started. Same trap on any platform whose interpreter startup is | |
| # heavier than the probe value. | |
| probe = ( | |
| "python3 -c 'import resource;print(resource.getrlimit(resource.RLIMIT_DATA)[0])'" | |
| ) | |
| out = subprocess.run( | |
| ["/bin/sh", "-c", f"{_data_ulimit_cap(_PROBE_MB)}{probe}"], | |
| capture_output=True, text=True, timeout=15, | |
| ) | |
| effective = out.stdout.strip() == str(_PROBE_MB * 1024 * 1024) | |
| except Exception: | |
| pass | |
| if not effective: | |
| logger.warning( | |
| "ulimit -d has no effect on this platform (%s): model-authored commands " | |
| "run WITHOUT a per-process memory cap, so a runaway allocation can take " | |
| "the whole container down. Expected on macOS; on a Linux worker this is " | |
| "a misconfiguration worth investigating.", | |
| sys.platform, | |
| ) | |
| _DATA_CAP_EFFECTIVE = effective | |
| return effective | |
| def _container_mem_limit_mb() -> int: | |
| """Per-exec RLIMIT_DATA cap (MB) for the ``container`` backend, i.e. model | |
| code running directly in the worker container. 0 = disabled. | |
| This backend had NO memory cap at all, which is how a sub-agent's own | |
| runaway tokenizer took 8GB in two minutes and got the whole pod OOM-killed: | |
| the kernel reaped the pod's cgroup, so the harness and the worker shell died | |
| with it, the task surfaced as an unexplained heartbeat timeout, and the | |
| trace upload (which runs after the agent exits) never happened. With the cap | |
| the same code dies alone with a MemoryError the model can read and react to. | |
| Sizing: the cap must be > the heaviest legitimate tool and small enough that | |
| K concurrent execs cannot collectively reach the pod's cgroup limit. | |
| Measured peaks for this image's tool set are well under 1GB (LibreOffice | |
| headless conversion being the largest), and with agent_bus_max_parallel=8 on | |
| an 8Gi worker 8 x 1024MB is exactly the container limit — hence 1024 rather | |
| than something roomier. It is a cap per PROCESS, not a budget, so a | |
| deployment with a different fan-out or memory_limit should set this to | |
| roughly ``pod_memory_limit / max_parallel``. Keep this literal in sync with | |
| ``sandbox_container_mem_mb`` in config.py — this is only the env fallback. | |
| """ | |
| return _mem_limit_mb("sandbox_container_mem_mb", "SANDBOX_CONTAINER_MEM_MB", 1024) | |
| def _local_mem_limit_mb() -> int: | |
| """Per-exec cap for worker-local model code, including bwrap fallback.""" | |
| return _mem_limit_mb("sandbox_local_mem_mb", "SANDBOX_LOCAL_MEM_MB", 640) | |
| def remote_exec_prefix() -> str: | |
| """Shell prefix for execs inside REMOTE sandboxes (E2B / Docker): a | |
| ``ulimit -v`` memory cap plus single-threaded math-lib env. | |
| Without this guard, on the 512MB base template a buffered | |
| big-file parse OOM-killed the whole VM → opaque "exit code -1" tool | |
| errors. With the cap, the python process trips RLIMIT_AS first and dies | |
| with a clean MemoryError traceback the model can act on, and the VM | |
| survives. Remote sandboxes are always Linux. | |
| """ | |
| parts = [] | |
| mem_mb = _e2b_mem_limit_mb() | |
| if mem_mb > 0: | |
| parts.append(f"ulimit -v {mem_mb * 1024} 2>/dev/null;") | |
| caps = " ".join(f"{k}={v}" for k, v in _THREAD_CAP_ENV.items()) | |
| parts.append(caps) | |
| return " ".join(parts) + " " | |
| # ── Bubblewrap Sandbox (per-task filesystem isolation) ───────────────── | |
| _BWRAP_PATH = shutil.which("bwrap") | |
| _BWRAP_USABLE: bool | None = None | |
| def bwrap_available() -> bool: | |
| """True on Linux when ``bwrap`` is present and usable.""" | |
| global _BWRAP_USABLE | |
| if not sys.platform.startswith("linux"): | |
| return False | |
| if _BWRAP_PATH is None: | |
| return False | |
| if _BWRAP_USABLE is not None: | |
| return _BWRAP_USABLE | |
| try: | |
| result = subprocess.run( | |
| [_BWRAP_PATH, *_bwrap_base_args(), "--", "true"], | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| ) | |
| _BWRAP_USABLE = result.returncode == 0 | |
| if not _BWRAP_USABLE: | |
| logger.warning("bubblewrap probe failed: %s", result.stderr.strip()) | |
| except Exception as exc: | |
| logger.warning("bubblewrap probe failed: %s", exc) | |
| _BWRAP_USABLE = False | |
| return _BWRAP_USABLE | |
| # System paths bound read-only into every bwrap sandbox. ``/lib64`` is absent | |
| # on some distros, so each is bound only when it exists. The base interpreter of | |
| # the harness venv lives under ``/usr`` (``/usr/local/...`` on the musl images), | |
| # so binding ``/usr`` also exposes it; the venv itself is added by | |
| # ``_venv_bind_args``. ``/proc`` is deliberately NOT here — see ``--proc`` below. | |
| _BWRAP_SYSTEM_PATHS = ("/usr", "/bin", "/lib", "/lib64", "/etc") | |
| # Recovery store below the workspace. Kept in sync with ``_overflow``'s | |
| # ``_WORKSPACE_SUBDIR`` by name rather than by import: ``_overflow`` imports this | |
| # module (lazily, from inside its functions), so a module-level import back the | |
| # other way would be a cycle. | |
| _SPILL_SUBDIR = ".spill" | |
| def _interpreter_bind_args() -> list[str]: | |
| """Expose the running interpreter read-only at its real path(s). | |
| The runtime images bake the ``sandbox`` extra (numpy/pandas/scipy/sympy) | |
| into ``/opt/venv`` and put ``/opt/venv/bin`` on ``PATH``. Without mounting | |
| that venv into the sandbox the directory is absent, so ``python3`` falls | |
| through to a bare system interpreter with none of the scientific stack. | |
| Bind both ``sys.prefix`` (the venv) and ``sys.base_prefix`` (the base | |
| interpreter a venv's ``bin/python`` symlinks into, whose stdlib the venv | |
| resolves through). On the images base_prefix sits under ``/usr`` and is | |
| already exposed, but on pyenv/conda/Homebrew deployments it lives elsewhere | |
| and the venv is unusable without it. Anything already covered by a | |
| system-path bind — or by an earlier entry here — is skipped, so a non-venv | |
| interpreter (``sys.prefix == sys.base_prefix`` under ``/usr``) emits nothing. | |
| NB: fed into every BwrapSandbox via ``_bwrap_base_args``, so a future | |
| bwrap-backed per-task (SWE) sandbox would also see the harness interpreter | |
| mounted read-only at its real path, alongside the task's own env. | |
| """ | |
| args: list[str] = [] | |
| emitted: list[str] = [] | |
| for raw in (sys.prefix, sys.base_prefix): | |
| prefix = os.path.realpath(raw) | |
| if any(_path_within(prefix, p) for p in (*_BWRAP_SYSTEM_PATHS, *emitted)): | |
| continue | |
| if not Path(prefix).exists(): | |
| continue | |
| emitted.append(prefix) | |
| args.extend(["--ro-bind", prefix, prefix]) | |
| return args | |
| def _extra_tool_root_bind_args() -> list[str]: | |
| """Expose configured non-system CLI roots read-only inside bwrap.""" | |
| raw = os.environ.get(_EXTRA_TOOL_ROOTS_ENV, _DEFAULT_EXTRA_TOOL_ROOTS) | |
| args: list[str] = [] | |
| emitted: list[str] = [] | |
| for item in raw.split(os.pathsep): | |
| item = item.strip() | |
| if not item or not os.path.isabs(item): | |
| continue | |
| root = os.path.realpath(item) | |
| if any(_path_within(root, parent) for parent in (*_BWRAP_SYSTEM_PATHS, *emitted)): | |
| continue | |
| if not Path(root).is_dir(): | |
| continue | |
| emitted.append(root) | |
| args.extend(["--ro-bind", root, root]) | |
| return args | |
| def _bwrap_base_args() -> list[str]: | |
| """Build conservative bubblewrap args for the current host.""" | |
| args: list[str] = [ | |
| "--unshare-user", | |
| "--unshare-ipc", | |
| "--die-with-parent", | |
| ] | |
| # A PID namespace is only usable together with a fresh procfs (see the | |
| # /proc note at the end of this function), so the two move together. | |
| if _sandbox_profile() == _PROFILE_SERVICE: | |
| args.insert(1, "--unshare-pid") | |
| # Hard network isolation for sandboxed bash/python (web_search/web_fetch run | |
| # in-harness, not here, so the agent's search tools are unaffected). This is | |
| # the real network boundary; ``_net_guard.py``'s byte cap is best-effort | |
| # defense-in-depth for backends without it. Gated by env (default on) so an | |
| # outer container that rejects net-namespace creation can fall back — | |
| # ``_bwrap_base_args`` feeds ``bwrap_available()``'s probe, so a rejected | |
| # ``--unshare-net`` would otherwise disable bwrap entirely. | |
| if os.environ.get("SANDBOX_UNSHARE_NET", "1").strip().lower() not in ("0", "false", "no"): | |
| args.append("--unshare-net") | |
| for path in _BWRAP_SYSTEM_PATHS: | |
| if Path(path).exists(): | |
| args.extend(["--ro-bind", path, path]) | |
| args.extend(_interpreter_bind_args()) | |
| args.extend(_extra_tool_root_bind_args()) | |
| # The tool user's overlay venv sits outside the system paths above, so bind | |
| # it too — otherwise PATH names a directory absent from the jail and | |
| # ``python3`` silently resolves to a different interpreter than in container | |
| # mode. Read-only, because the overlay lives for the whole container while a | |
| # bwrap jail is per-command: a writable bind would let one command persist | |
| # code there for every later command (and for container-mode execs) to | |
| # import. Model bash DOES have network here — ``bash.py`` passes | |
| # ``allow_net=True``, which strips ``--unshare-net`` above — so installs are | |
| # possible and must land somewhere writable; ``_build_tool_env``'s | |
| # ``overlay_writable=False`` points pip at the workspace HOME instead. | |
| overlay = _tool_overlay_venv(os.environ) | |
| if overlay: | |
| args.extend(["--ro-bind", overlay, overlay]) | |
| # ── /proc, and why it differs by profile ───────────────────────────── | |
| # | |
| # ``service``: mount a FRESH procfs for the unshared PID namespace. A bind | |
| # of the outer /proc keeps the host process view (defeating --unshare-pid) | |
| # and exposes /proc/<pid>/{environ,root} — on a multi-tenant worker that | |
| # means one task's model code can read the *service's* provider | |
| # credentials out of the harness process. There, the fresh procfs is the | |
| # boundary and there is no acceptable fallback: if a runtime forbids it, | |
| # ``bwrap_available()``'s probe (which runs these same args) fails and the | |
| # caller fails closed rather than binding the outer instance. | |
| # | |
| # ``local`` (default): a single-user install has no second tenant to | |
| # protect from. What the jail is for here is the *filesystem* — the agent | |
| # gets the working directory read-write and a read-only system, and never | |
| # sees ~/.ssh, ~/.aws or the rest of $HOME. The credentials reachable | |
| # through a bound /proc are the user's own, and are already the keys the | |
| # agent is calling the model with, so the fresh procfs buys almost nothing | |
| # while costing a great deal: mounting one needs privileges that neither a | |
| # stock Docker seccomp profile nor many unprivileged container runtimes | |
| # grant, and without it the whole sandbox is unavailable. | |
| # | |
| # So local mode binds /proc read-only and drops --unshare-pid, keeping the | |
| # two consistent (a PID namespace whose /proc shows host processes is worse | |
| # than no namespace: `ps` and anything reading /proc/self lie). | |
| # | |
| # The cost is real and worth stating: without a PID namespace a command | |
| # that backgrounds a process can leave it running after the command's | |
| # timeout, because killing the sandbox no longer tears down a namespace. | |
| if _sandbox_profile() == _PROFILE_SERVICE: | |
| args.extend(["--proc", "/proc"]) | |
| else: | |
| args.extend(["--ro-bind", "/proc", "/proc"]) | |
| args.extend(["--dev", "/dev"]) | |
| return args | |
| def _bwrap_mem_limit_mb() -> int: | |
| """Per-command virtual-memory cap (MB) for BwrapSandbox. 0 = unlimited. | |
| A ceiling, not a reservation — nothing is pre-allocated; an allocation only | |
| fails once the command's total virtual memory (VSZ, not RSS) would exceed | |
| it, protecting the pod from a single runaway bash call. Kept generous | |
| (default 12 GB) because runtimes reserve large virtual regions they never | |
| touch. Applied via a ``ulimit -v`` shell prefix (thread-safe, the codebase | |
| idiom — see _ulimit_cap) rather than a preexec_fn.""" | |
| return _mem_limit_mb("sandbox_bwrap_mem_mb", "SANDBOX_BWRAP_MEM_MB", 12 * 1024) | |
| class _BwrapCommands: | |
| """Command executor that wraps each command in a bubblewrap sandbox.""" | |
| def __init__( | |
| self, | |
| bind_args: list[str], | |
| chdir: str, | |
| *, | |
| mem_limit_mb: int | None = None, | |
| ) -> None: | |
| self._bind_args = bind_args | |
| self._chdir = chdir | |
| self._mem_limit_mb = mem_limit_mb | |
| def run(self, command: str, timeout: int = 60, | |
| input: str | None = None, allow_net: bool = False, | |
| env_allow: tuple[str, ...] = ()) -> _CommandResult: | |
| if _BWRAP_PATH is None: | |
| return _CommandResult(stderr="bubblewrap (bwrap) is not installed", exit_code=127) | |
| mem_limit_mb = ( | |
| _bwrap_mem_limit_mb() | |
| if self._mem_limit_mb is None | |
| else self._mem_limit_mb | |
| ) | |
| capped = _ulimit_cap(mem_limit_mb) + command | |
| # Most commands retain the isolated network namespace. Callers that | |
| # intentionally provide a network-backed capability (read_file OCR, | |
| # controlled downloads, or bash research) opt in explicitly. | |
| base_args = _bwrap_base_args() | |
| if allow_net: | |
| base_args = [a for a in base_args if a != "--unshare-net"] | |
| argv = [ | |
| _BWRAP_PATH, | |
| *base_args, | |
| *self._bind_args, | |
| "--chdir", | |
| self._chdir, | |
| "--", | |
| "bash", | |
| # ``-c`` (not ``-lc``): a *login* shell sources host /etc/profile.d/* | |
| # which prints a multi-KB MOTD banner onto stdout, corrupting every | |
| # parsed tool result (JSON / ls / cat). PATH is provided via env, so | |
| # binaries are still found without the login-shell profile. | |
| "-c", | |
| capped, | |
| ] | |
| try: | |
| result = subprocess.run( | |
| argv, | |
| capture_output=True, | |
| text=True, | |
| timeout=timeout, | |
| env=_build_tool_env( | |
| os.environ.copy(), | |
| home="/workspace", | |
| env_allow=env_allow, | |
| # The jail mounts the overlay venv read-only (see | |
| # ``_bwrap_base_args``), so pip goes to the workspace HOME. | |
| overlay_writable=False, | |
| ), | |
| input=input, | |
| ) | |
| return _CommandResult( | |
| stdout=result.stdout, | |
| stderr=result.stderr, | |
| exit_code=result.returncode, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise TimeoutError(f"Command timed out after {timeout}s") from exc | |
| except Exception as e: | |
| return _CommandResult(stderr=str(e), exit_code=1) | |
| class _BwrapFiles: | |
| """Minimal E2B-like file API for code paths that call ``files.write``.""" | |
| def __init__(self, commands: _BwrapCommands) -> None: | |
| self._commands = commands | |
| def write(self, path: str, content: str) -> None: | |
| import base64 | |
| b64c = base64.b64encode(content.encode()).decode() | |
| b64p = base64.b64encode(path.encode()).decode() | |
| cmd = ( | |
| "python3 -c \"import base64, os; " | |
| f"p = base64.b64decode('{b64p}').decode(); " | |
| "os.makedirs(os.path.dirname(p) or '.', exist_ok=True); " | |
| f"open(p, 'wb').write(base64.b64decode('{b64c}'))\"" | |
| ) | |
| result = self._commands.run(cmd, timeout=30) | |
| if result.exit_code != 0: | |
| raise RuntimeError(result.stderr.strip() or "bwrap file write failed") | |
| class BwrapSandbox: | |
| """Per-task bubblewrap sandbox with an E2B-compatible ``commands.run`` API. | |
| Args: | |
| workspace: host directory bound read-write at ``/workspace``. Commands | |
| run with ``/workspace`` as their current directory. | |
| binds: extra mounts as ``(host_src, sandbox_dst, read_only)`` tuples. | |
| Read-only missing paths are skipped; read-write paths are created. | |
| mem_limit_mb: optional per-command virtual-memory ceiling. The generic | |
| model-code fallback passes the tighter worker-local limit; callers | |
| that omit it retain the larger file-processing limit. | |
| """ | |
| def __init__( | |
| self, | |
| *, | |
| workspace: str | Path | None = None, | |
| binds: tuple[tuple[str, str, bool], ...] = (), | |
| mem_limit_mb: int | None = None, | |
| ) -> None: | |
| if not bwrap_available(): | |
| raise SandboxUnavailableError( | |
| "bubblewrap fallback requires Linux, an installed bwrap binary, " | |
| "and usable user namespaces" | |
| ) | |
| self._owns_workspace = workspace is None | |
| if workspace is None: | |
| workspace_path = Path( | |
| tempfile.mkdtemp(prefix="frontier_agent-bwrap-workspace-") | |
| ).resolve() | |
| else: | |
| workspace_path = Path(workspace).expanduser().resolve() | |
| workspace_path.mkdir(parents=True, exist_ok=True) | |
| tmp_path = Path(tempfile.mkdtemp(prefix="frontier_agent-bwrap-tmp-")).resolve() | |
| dir_args = [ | |
| "--dir", | |
| "/workspace", | |
| "--dir", | |
| "/inputs", | |
| "--dir", | |
| "/outputs", | |
| "--dir", | |
| "/tmp", | |
| ] | |
| bind_args: list[str] = [ | |
| *dir_args, | |
| "--bind", | |
| str(workspace_path), | |
| "/workspace", | |
| "--bind", | |
| str(tmp_path), | |
| "/tmp", | |
| ] | |
| for src, dst, read_only in binds: | |
| src_path = Path(src).expanduser().resolve() | |
| if not src_path.exists(): | |
| if read_only: | |
| logger.warning("BwrapSandbox: read-only input missing, skipped: %s", src) | |
| continue | |
| src_path.mkdir(parents=True, exist_ok=True) | |
| parent_dst = str(Path(dst).parent) | |
| if parent_dst not in {".", "/"}: | |
| bind_args.extend(["--dir", parent_dst]) | |
| bind_args.extend( | |
| (["--ro-bind"] if read_only else ["--bind"]) | |
| + [str(src_path), str(dst)] | |
| ) | |
| # Mount the spill store read-only at its own top-level path. | |
| # | |
| # Without a mount the read-only store is only a lexical promise: | |
| # ``_deliverable_policy`` refuses every natural way to write there — | |
| # including bash, which IS token-scanned — but shell expansion can hide a | |
| # path from any scanner (a glob, a brace, a ``$VAR`` assembled in pieces, | |
| # a ``$(…)`` substitution), and the files are ordinary 0644 files. File | |
| # modes cannot close it either — model commands run as uid 0 inside the | |
| # user namespace, so DAC is not consulted. A mount is, which is why this | |
| # is the layer that actually holds. | |
| # | |
| # This no longer has to be the LAST bind to win: the source now sits | |
| # outside the workspace, so nothing above it overlaps and the ordering | |
| # constraint that used to be load-bearing is gone. | |
| # | |
| # ``--ro-bind-try``, not ``--ro-bind``: the store is created lazily on the | |
| # first spill, and bwrap aborts the whole jail when a ``--ro-bind`` source | |
| # is missing. Args are rebuilt per command, so the mount appears as soon | |
| # as the directory does — and until then there is nothing to protect. | |
| # | |
| # The harness writes spill through the host filesystem, not through the | |
| # jail, so this constrains model commands only. | |
| bind_args.extend([ | |
| "--ro-bind-try", str(spill_root()), _DEFAULT_SPILL_DIR, | |
| ]) | |
| self._workdir = str(workspace_path) | |
| self._tmpdir = str(tmp_path) | |
| self.commands = _BwrapCommands( | |
| bind_args, "/workspace", mem_limit_mb=mem_limit_mb, | |
| ) | |
| self.files = _BwrapFiles(self.commands) | |
| self.sandbox_id = f"bwrap-{workspace_path.name}" | |
| logger.info("BwrapSandbox created: id=%s binds=%s", self.sandbox_id, bind_args) | |
| def kill(self) -> None: | |
| """Clean up sandbox-owned temporary directories.""" | |
| with contextlib.suppress(Exception): | |
| shutil.rmtree(self._tmpdir, ignore_errors=True) | |
| if self._owns_workspace: | |
| with contextlib.suppress(Exception): | |
| shutil.rmtree(self._workdir, ignore_errors=True) | |
| # ── Current Container Sandbox (for Harbor / pre-provisioned workspaces) ── | |
| # Largest write guaranteed not to block on a pipe the kernel reported writable. | |
| # Same constant, same reason, as subprocess._PIPE_BUF. | |
| _PIPE_BUF = getattr(select, "PIPE_BUF", 512) | |
| # How long past the deadline a command that has ALREADY EXITED may keep | |
| # streaming its tail before we give up. See the grace note in _stream_capped. | |
| _POST_EXIT_GRACE_STEP_S = 0.25 | |
| def _incremental_text_decoder(stream: Any) -> io.IncrementalNewlineDecoder: | |
| """Reproduce what ``text=True`` would have done to *stream*, incrementally. | |
| We read raw bytes off the fd and cannot decode at the end the way | |
| ``communicate()`` does, because we do not keep the bytes. Two things then | |
| only an incremental decoder gets right, and both are real: a multi-byte | |
| character split across an ``os.read`` boundary, and a ``\\r\\n`` split | |
| across one (``translate=True`` is the newline half of ``text=True``). | |
| ``errors="replace"``, deliberately not the strict default. Under strict, one | |
| stray non-UTF-8 byte — ``cat`` of a binary, ``grep`` in a JPEG, a tool that | |
| prints latin-1 — raises mid-capture. In the previous thread-per-stream | |
| design that killed the pump, so nobody drained that pipe again and the child | |
| blocked in ``write()`` until the timeout with all of its output discarded. | |
| A tool result is for a model to read; U+FFFD is the right answer. | |
| """ | |
| enc = getattr(stream, "encoding", None) or "utf-8" | |
| return io.IncrementalNewlineDecoder(codecs.getincrementaldecoder(enc)("replace"), True) | |
| def _release_pipe(sel: selectors.BaseSelector, fileobj: Any) -> None: | |
| """Unregister *fileobj* from *sel* and close it. | |
| Cannot block: this thread is the only reader/writer of these objects, so | |
| nobody else holds their buffer lock. That is the invariant the whole | |
| single-threaded design exists to provide — see _stream_capped. | |
| """ | |
| with contextlib.suppress(KeyError, ValueError): | |
| sel.unregister(fileobj) | |
| with contextlib.suppress(Exception): | |
| fileobj.close() | |
| def _stream_capped( | |
| proc: subprocess.Popen, *, input: str | None, timeout: float, | |
| ) -> tuple[str, str]: | |
| """``Popen.communicate`` with a retention cap instead of unbounded buffering. | |
| Drop-in for communicate(): feeds stdin, reads stdout and stderr, waits for | |
| exit, and raises ``subprocess.TimeoutExpired`` past the deadline so the | |
| caller's existing handling is unchanged. Structured like CPython's POSIX | |
| ``Popen._communicate``: one selector over all three raw fds, driven from the | |
| CALLING thread. | |
| **Why not a thread per stream.** That was the first implementation and it | |
| could wedge the caller forever. A pump parked in ``read()`` holds its | |
| ``BufferedReader`` lock, and the ``close()`` in ``_CurrentCommands.run``'s | |
| ``finally`` needs that same lock — so cleanup blocked, and ``run()`` neither | |
| returned nor raised. It needed only a grandchild outside the process group | |
| (``setsid``, or ``Popen(..., start_new_session=True)`` — a very ordinary way | |
| for model code to start a background server): ``killpg`` cannot reach it, so | |
| the pipe never EOFs. Measured on Linux 5.10 / CPython 3.14, **nothing** | |
| rescues that pump — not ``os.close(fd)``, not ``dup2`` over it, not | |
| ``pthread_kill``; a blocked ``read(2)`` on a pipe is uninterruptible, and | |
| ``close()``/``detach()`` from another thread are themselves the deadlock. | |
| So the fix is not to park: one thread, one selector, every ``close()`` | |
| lock-free by construction. | |
| Reading both pipes in one selector is also what makes the naive-loop | |
| objection moot — neither "the child filled the pipe you are not reading" nor | |
| "the child will not read stdin until it has written output" can arise when | |
| all three fds are polled together. | |
| Retention is capped but reads never stop: see ``_CappedSink``. Stopping the | |
| reads would block the child in ``write()``, which for a child that never | |
| exits on its own is the very hang this backend's ``killpg`` contract exists | |
| to prevent. | |
| """ | |
| if os.name != "posix": # pragma: no cover - workers are Linux | |
| # selectors cannot poll pipes on Windows; CPython falls back to threads | |
| # there too. Uncapped, but this backend does not run on Windows. | |
| stdout, stderr = proc.communicate(input=input, timeout=timeout) | |
| return stdout or "", stderr or "" | |
| cap = _output_cap_bytes() | |
| sinks = {"stdout": _CappedSink(cap), "stderr": _CappedSink(cap)} | |
| names: dict[Any, str] = {} | |
| decoders: dict[Any, io.IncrementalNewlineDecoder] = {} | |
| pending: memoryview | None = None | |
| offset = 0 | |
| # ONE deadline covering both process exit and pipe EOF, because that is | |
| # communicate()'s contract: it returns when the pipes EOF, not when the | |
| # shell exits. A command that exits 0 but leaves a daemon holding the pipe | |
| # must still surface as a timeout (see the "Edge:" note in | |
| # _CurrentCommands.run) rather than returning early with partial output. | |
| deadline = time.monotonic() + timeout | |
| hard_deadline = deadline + _POST_EXIT_DRAIN_S | |
| progressed = False | |
| with selectors.DefaultSelector() as sel: | |
| if proc.stdin is not None: | |
| raw = (input or "").encode( | |
| getattr(proc.stdin, "encoding", None) or "utf-8", "replace", | |
| ) | |
| if raw: | |
| pending = memoryview(raw) | |
| sel.register(proc.stdin, selectors.EVENT_WRITE) | |
| else: | |
| _release_pipe(sel, proc.stdin) # EOF at once, as communicate() does | |
| for name in ("stdout", "stderr"): | |
| stream = getattr(proc, name) | |
| if stream is not None: | |
| names[stream] = name | |
| decoders[stream] = _incremental_text_decoder(stream) | |
| sel.register(stream, selectors.EVENT_READ) | |
| while sel.get_map(): | |
| now = time.monotonic() | |
| if now >= deadline: | |
| # Post-exit drain grace. A command that exits at 299.9s of a | |
| # 300s timeout would otherwise be reported as a timeout WITH ITS | |
| # OUTPUT DISCARDED even though it succeeded — communicate() | |
| # drains without a timeout once the process is gone. Granting | |
| # the grace unconditionally is wrong the other way: it turned | |
| # every `foo &` timeout into timeout + 5s. So extend in small | |
| # steps, only while the process has exited AND the last select | |
| # actually delivered bytes, hard-capped at +_POST_EXIT_DRAIN_S. | |
| if not progressed or proc.poll() is None or deadline >= hard_deadline: | |
| raise subprocess.TimeoutExpired(proc.args, timeout) | |
| deadline = min(now + _POST_EXIT_GRACE_STEP_S, hard_deadline) | |
| progressed = False | |
| for key, _events in sel.select(max(0.0, deadline - time.monotonic())): | |
| if key.fileobj is proc.stdin: | |
| # At most _PIPE_BUF to a pipe the kernel called writable, so | |
| # this cannot block. Closing on EPIPE is the child's choice. | |
| assert pending is not None | |
| try: | |
| offset += os.write(key.fd, pending[offset:offset + _PIPE_BUF]) | |
| except OSError: | |
| _release_pipe(sel, key.fileobj) | |
| else: | |
| if offset >= len(pending): | |
| _release_pipe(sel, key.fileobj) | |
| continue | |
| # Readable means data or EOF is pending, so this cannot block | |
| # either — which is why the fds stay in blocking mode. | |
| chunk = os.read(key.fd, 65536) | |
| sink = sinks[names[key.fileobj]] | |
| decoder = decoders[key.fileobj] | |
| if chunk: | |
| progressed = True | |
| sink.feed(decoder.decode(chunk)) | |
| else: | |
| sink.feed(decoder.decode(b"", True)) # flush a split character | |
| _release_pipe(sel, key.fileobj) | |
| # Every pipe hit EOF, so every process that held one is gone; what is | |
| # left is the reap, under the same deadline — a shell that detached its | |
| # own stdio (`exec >/dev/null 2>&1; sleep 30`) EOFs immediately and must | |
| # still time out rather than being waited on forever. | |
| proc.wait(timeout=max(0.0, deadline - time.monotonic())) | |
| return sinks["stdout"].value(), sinks["stderr"].value() | |
| _SETPRIV_PATH = shutil.which("setpriv") | |
| _SETPRIV_MISSING_WARNED = False | |
| _ULIMIT_CGROUP_ORDER_CHECKED = False | |
| def _check_ulimit_below_cgroup(mem_mb: int) -> None: | |
| """Warn once if ``ulimit -d`` >= the per-exec ``memory.max``. | |
| The two caps have OPPOSITE failure semantics and their order is what keeps | |
| the model able to self-correct: RLIMIT_DATA fails the allocation | |
| synchronously (MemoryError + traceback, actionable), the cgroup group-kills | |
| the whole tree (bare exit 137). The ulimit must therefore trip FIRST for | |
| the single-runaway-process case, leaving the cgroup to catch only what | |
| RLIMIT_DATA structurally cannot (fan-out, MAP_SHARED). Inverting the order | |
| silently downgrades every plain runaway to the unreadable signal. | |
| """ | |
| global _ULIMIT_CGROUP_ORDER_CHECKED | |
| if _ULIMIT_CGROUP_ORDER_CHECKED: | |
| return | |
| _ULIMIT_CGROUP_ORDER_CHECKED = True | |
| raw = (os.environ.get(_EXEC_CG_MEM_MAX_ENV) or "").strip() | |
| try: | |
| cg_max = int(raw) | |
| except ValueError: | |
| return | |
| if cg_max <= 0: | |
| return | |
| if mem_mb <= 0: | |
| logger.warning( | |
| "ulimit -d is disabled while the per-exec cgroup is armed: EVERY " | |
| "runaway — including plain single-process ones — will be group-" | |
| "killed to exit 137 instead of raising a readable MemoryError. " | |
| "Set SANDBOX_CONTAINER_MEM_MB below WORKER_EXEC_MEM_MAX_BYTES.", | |
| ) | |
| elif mem_mb * 1024 * 1024 >= cg_max: | |
| logger.warning( | |
| "ulimit -d (%d MB) >= per-exec memory.max (%d MB): the cgroup will " | |
| "group-kill before RLIMIT_DATA can raise MemoryError, so models get " | |
| "exit 137 instead of a readable traceback. Lower " | |
| "SANDBOX_CONTAINER_MEM_MB or raise WORKER_EXEC_MEM_MAX_BYTES.", | |
| mem_mb, cg_max // (1024 * 1024), | |
| ) | |
| class _CurrentCommands: | |
| """Command executor for an existing checkout in the current process. | |
| Model-authored commands are dropped to an unprivileged uid when one is | |
| available (see :func:`tool_identity`) — that, not the filesystem layout, is | |
| what keeps them out of ``/proc/<harness-pid>/environ``. | |
| """ | |
| def __init__(self, workdir: str, *, private_tmp: bool = False) -> None: | |
| self._workdir = workdir | |
| self._identity = tool_identity() | |
| native = os.environ.get("APODEX_IN_NATIVE", "").strip() == "1" | |
| self._runtime_home = ( | |
| os.environ.get("HOME", "").strip() or workdir | |
| if native else workdir | |
| ) | |
| # Per-agent scratch root. Only meaningful when this executor owns a | |
| # private workdir (a sub-agent); the task-wide sandbox keeps the | |
| # shared /tmp so the documented ``read_file(save_to="/tmp/...")`` and | |
| # skill conventions still resolve to the same place for everyone. | |
| self._tmpdir = ( | |
| os.environ.get("TMPDIR", "").strip() or "/tmp" | |
| if native else "/tmp" | |
| ) | |
| if private_tmp: | |
| candidate = Path(workdir) / ".tmp" | |
| try: | |
| candidate.mkdir(parents=True, exist_ok=True) | |
| if self._identity is not None: | |
| candidate.chmod(0o777) | |
| self._tmpdir = str(candidate) | |
| except OSError as exc: | |
| logger.warning( | |
| "private TMPDIR under %s unavailable (%s); falling back to " | |
| "the shared /tmp", workdir, exc, | |
| ) | |
| def _privilege_kwargs(self) -> dict[str, Any]: | |
| """Popen kwargs that drop the child to the unprivileged tool user.""" | |
| if self._identity is None: | |
| return {} | |
| return { | |
| "user": self._identity.uid, | |
| "group": self._identity.gid, | |
| # Explicit: without it the child keeps root's supplementary groups | |
| # and the uid drop would be cosmetic for group-granted access. | |
| "extra_groups": [], | |
| } | |
| def _cgroup_wrapped( | |
| self, command: str, mem_mb: int, | |
| ) -> tuple[str, ExecCgroup] | None: | |
| """Per-exec cgroup form of the command, or ``None`` to run as today. | |
| Shape (root shell, then drop): | |
| echo $$ > <cg>/cgroup.procs; ulimit -d ...; \\ | |
| exec setpriv --reuid=U --regid=G --clear-groups /bin/sh -c <command> | |
| The self-migration MUST happen before the privilege drop and cannot be | |
| done by the tool uid itself: cgroup v2 delegation containment also | |
| requires write access to the source/destination COMMON ANCESTOR's | |
| ``cgroup.procs`` (the container root, root:root), verified on a real | |
| worker node — an unprivileged writer fails even with the leaf files | |
| chmod'd open. That containment is the lock keeping model code from | |
| migrating itself OUT of its limit; do not loosen it. Hence root writes | |
| ``$$`` first, then ``exec setpriv`` replaces Popen's user/group/ | |
| extra_groups kwargs (CPython applies those BEFORE any hook we own). | |
| Rlimits and cgroup membership both survive exec. | |
| Fail-open on missing pieces (no cgroup root, mkdir failed), fail-closed | |
| on the privilege drop itself: if ``setpriv`` is absent from PATH at | |
| exec time the shell exits 127 and the command never runs as root. | |
| """ | |
| if self._identity is not None and _SETPRIV_PATH is None: | |
| # No way to both join the cgroup as root and drop privileges: | |
| # keep the (security-critical) uid drop, forgo the cgroup. | |
| # Checked BEFORE creating anything — this is a property of the | |
| # image, so creating (and immediately destroying) a cgroup per | |
| # exec would be pure churn. Warn once, like the other probes. | |
| global _SETPRIV_MISSING_WARNED | |
| if not _SETPRIV_MISSING_WARNED: | |
| _SETPRIV_MISSING_WARNED = True | |
| logger.warning( | |
| "setpriv not found; per-exec cgroup isolation disabled to " | |
| "keep the tool-user privilege drop", | |
| ) | |
| return None | |
| cg = create_exec_cgroup() | |
| if cg is None: | |
| return None | |
| _check_ulimit_below_cgroup(mem_mb) | |
| prefix = ( | |
| f"echo $$ > {shlex.quote(cg.procs_path)} 2>/dev/null; " | |
| + _data_ulimit_cap(mem_mb) | |
| ) | |
| if self._identity is None: | |
| return prefix + command, cg | |
| wrapped = ( | |
| prefix | |
| + f"exec {shlex.quote(_SETPRIV_PATH or '')}" | |
| + f" --reuid={self._identity.uid} --regid={self._identity.gid}" | |
| + f" --clear-groups /bin/sh -c {shlex.quote(command)}" | |
| ) | |
| return wrapped, cg | |
| def run(self, command: str, timeout: int = 60, | |
| input: str | None = None, | |
| env_allow: tuple[str, ...] = ()) -> _CommandResult: | |
| """Execute a command in the current container or host environment. | |
| Runs the shell in its OWN session/process group (start_new_session) | |
| and SIGKILLs that whole group in the ``finally``. This closes a | |
| subprocess capture-pipe hang: a command that backgrounds a daemon | |
| (``foo &``) leaves a grandchild holding the stdout/stderr pipe, and | |
| ``communicate()`` returns only on pipe EOF — not on the shell exiting | |
| — so it would block forever (even after a ``TimeoutExpired``, since | |
| CPython then re-drains the pipe with no timeout). ``killpg`` reaps the | |
| whole group so the pipes always EOF. The bwrap/E2B/Docker backends get | |
| this for free from their PID namespace / container; CurrentSandbox runs | |
| on the bare host, so it must reap the group itself. | |
| Edge: a command that finishes 0 but leaves a surviving daemon blocks | |
| until ``timeout`` and is then reported as a timeout (the daemon holds | |
| the pipe past the shell's exit). That is a strict improvement over the | |
| previous unbounded hang, and cross-call daemon persistence is not part | |
| of the one-shot sandbox-exec contract anyway. | |
| Edge: a grandchild that LEFT the process group (``setsid``, | |
| ``Popen(start_new_session=True)``) is out of ``killpg``'s reach. When | |
| the worker provides a per-exec cgroup (see ``_exec_cgroup``) the | |
| ``finally`` additionally kills through ``cgroup.kill``, which such a | |
| grandchild cannot escape; without one (old pod specs, local dev) it | |
| survives this call as before. Either way we time out, release our fds | |
| and return — see the ``finally`` and ``_stream_capped`` for why that is | |
| not merely likely but structural. | |
| """ | |
| # Memory cap, applied here rather than in each tool so every caller of | |
| # this backend is covered — bash, the read_file/create_file bundles and | |
| # download_file all previously ran with no memory limit whatsoever. | |
| # Mirrors _BwrapCommands.run, which caps in the backend for the same | |
| # reason. See _data_ulimit_cap / _container_mem_limit_mb. | |
| mem_mb = _container_mem_limit_mb() | |
| if mem_mb > 0: | |
| data_cap_effective() # probe once; warns loudly if it is a no-op here | |
| # Per-exec cgroup on top of (never instead of) the RLIMIT_DATA cap: | |
| # the ulimit keeps single-process runaways dying with a readable | |
| # MemoryError, the cgroup catches what a per-process limit cannot — | |
| # N compliant children summing past the budget, and MAP_SHARED mmaps. | |
| cg: ExecCgroup | None = None | |
| wrapped = self._cgroup_wrapped(command, mem_mb) | |
| if wrapped is not None: | |
| capped, cg = wrapped | |
| privilege_kwargs: dict[str, Any] = {} # setpriv drops instead | |
| else: | |
| capped = _data_ulimit_cap(mem_mb) + command | |
| privilege_kwargs = self._privilege_kwargs() | |
| try: | |
| proc = subprocess.Popen( | |
| capped, | |
| shell=True, | |
| stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| cwd=self._workdir, | |
| # Two independent measures, because either alone is bypassable: | |
| # a minimal env (the child cannot read what it was not given) | |
| # and an unprivileged uid (the child cannot read the parent's | |
| # /proc/<pid>/environ either). | |
| env=_build_tool_env( | |
| os.environ.copy(), | |
| home=self._runtime_home, | |
| env_allow=env_allow, | |
| tmpdir=self._tmpdir, | |
| ), | |
| start_new_session=True, | |
| **privilege_kwargs, | |
| ) | |
| except Exception as e: | |
| if cg is not None: | |
| cg.close() | |
| return _CommandResult(stderr=str(e), exit_code=1) | |
| # New session leader → its PGID equals its PID. Capture it now: once | |
| # the process is reaped, os.getpgid() would raise ProcessLookupError. | |
| pgid = proc.pid | |
| try: | |
| if cg is not None: | |
| # Idempotent backstop for the shell's own `echo $$ >` (whose | |
| # failure is silenced): the harness is root, so containment does | |
| # not apply to it. Children forked before this write would stay | |
| # behind, which is why the prefix remains the primary path. | |
| with contextlib.suppress(OSError), open(cg.procs_path, "w") as fh: | |
| fh.write(str(proc.pid)) | |
| _register_exec(pgid, command, cgroup=cg) | |
| stdout, stderr = _stream_capped(proc, input=input, timeout=timeout) | |
| if cg is not None and proc.returncode != 0: | |
| # A group kill SIGKILLs the shell too, so the model would see a | |
| # bare exit 137 — no MemoryError, no traceback, nothing to act | |
| # on. The cgroup still holds memory.peak / memory.events until | |
| # the finally rmdirs it; read them NOW and say what happened in | |
| # the channel the model reads errors from. | |
| # | |
| # Gated on a non-zero exit: with a degraded cgroup (the | |
| # memory.oom.group write failed) the kernel kills only the | |
| # biggest child, and a shell that recovered (`hog || fallback`) | |
| # can still exit 0 with oom_kill > 0 — a note would then tell | |
| # the model its SUCCESSFUL command was killed. | |
| note = cg.oom_note() | |
| if note: | |
| stderr = (stderr + "\n" if stderr else "") + note | |
| if _killed_by_guard(pgid) and proc.returncode != 0: | |
| # Tell the model WHY, in the same channel it reads errors from. | |
| # Without this the kill is indistinguishable from the command | |
| # crashing on its own, and "retry verbatim" looks reasonable. | |
| # | |
| # Same non-zero-exit gate as the oom_note above: the watchdog | |
| # can rank a command as the disposal victim in the gap after it | |
| # exited 0 but before this check (its cgroup still holds page | |
| # cache), and the killpg then hits nothing — telling the model | |
| # its SUCCESSFUL command was killed would make it "fix" it. | |
| stderr = (stderr + "\n" if stderr else "") + ( | |
| "[memory guard] this command was killed: the container was " | |
| "close to its memory limit and this was the largest running " | |
| "command. Reduce the working set (process in chunks, stream " | |
| "instead of loading whole files) or run fewer commands at once." | |
| ) | |
| return _CommandResult( | |
| stdout=stdout, | |
| stderr=stderr, | |
| exit_code=proc.returncode, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| # A kill and a timeout can coincide: the kill lands but a setsid | |
| # orphan keeps the pipes open past the deadline. The memory story | |
| # is the more actionable half — carry it into the timeout message | |
| # (the cgroup is still intact here; the finally below removes it). | |
| notes = [] | |
| if cg is not None: | |
| note = cg.oom_note() | |
| if note: | |
| notes.append(note) | |
| if _killed_by_guard(pgid): | |
| notes.append( | |
| "[memory guard] this command was killed mid-run: the " | |
| "container was close to its memory limit and this was the " | |
| "largest running command." | |
| ) | |
| raise TimeoutError( | |
| f"Command timed out after {timeout}s" | |
| + "".join(f"\n{n}" for n in notes) | |
| ) from exc | |
| except Exception as e: | |
| return _CommandResult(stderr=str(e), exit_code=1) | |
| finally: | |
| _unregister_exec(pgid) | |
| # Reap any surviving group member (a backgrounded grandchild still | |
| # holding the pipe, or the shell itself on the timeout path). | |
| # Harmless when the leader already exited (ProcessLookupError). | |
| with contextlib.suppress(ProcessLookupError, PermissionError, OSError): | |
| os.killpg(pgid, signal.SIGKILL) | |
| if cg is not None: | |
| # cgroup.kill + rmdir. The kill reaches setsid escapees killpg | |
| # cannot; the rmdir is load-bearing — a leaked exec cgroup pins | |
| # node-level slab that NO container limit accounts for (see | |
| # ExecCgroup.close). Before pipe close on purpose: it reaps | |
| # writers, so the drain below sees EOF instead of a live fd. | |
| cg.close() | |
| # Lock-free BY CONSTRUCTION: _stream_capped reads and writes these | |
| # pipes from THIS thread, so no other thread can be parked inside a | |
| # BufferedReader/Writer holding its lock. That invariant is the whole | |
| # reason the capture loop is a selector and not a thread per stream — | |
| # with a parked pump this loop blocked forever on the first close() | |
| # and run() neither returned nor raised. killpg above cannot reach a | |
| # grandchild that left the process group (setsid / | |
| # Popen(start_new_session=True)), and nothing else can interrupt a | |
| # read(2) on a pipe. Do not add os.close(fd)/dup2 "unblock" attempts: | |
| # measured, they neither wake the reader nor are they safe (they free | |
| # an fd number another thread still intends to use). | |
| # * read ends: their wrapper buffers are empty (we never read | |
| # through them), so close() is just a close(2). | |
| # * write end: every stdin byte went out via os.write, so there is | |
| # nothing buffered to flush and close() cannot block on a full | |
| # pipe either. | |
| for stream in (proc.stdout, proc.stderr, proc.stdin): | |
| if stream is not None: | |
| with contextlib.suppress(Exception): | |
| stream.close() | |
| with contextlib.suppress(Exception): | |
| proc.wait(timeout=5) | |
| # Inner command sandboxes, keyed by workspace dir. In container mode the main | |
| # agent and every sub-agent attach their own ``CurrentSandbox`` to the SAME | |
| # mounted /workspace, and none of them owns the mount's lifecycle — so none of | |
| # them calls ``kill()``. Building a fresh BwrapSandbox per attach therefore | |
| # leaked one mkdtemp'd /tmp per sub-agent for the life of the container, and | |
| # gave each agent a private /tmp that the others could not see. One inner | |
| # sandbox per workspace restores the shared-mount semantics and the leak. | |
| _INNER_SANDBOXES: dict[str, BwrapSandbox] = {} | |
| _INNER_SANDBOX_LOCK = threading.Lock() | |
| def _shared_inner_sandbox(workdir: str) -> BwrapSandbox: | |
| """Return the process-wide inner bwrap sandbox for *workdir*.""" | |
| with _INNER_SANDBOX_LOCK: | |
| existing = _INNER_SANDBOXES.get(workdir) | |
| if existing is not None: | |
| return existing | |
| _, outputs_dir, inputs_dir = resolve_mount_dirs() | |
| # /outputs is bound unconditionally. Skipping a missing one (as a | |
| # conditional bind would) is the worst outcome: the model's writes land | |
| # in the sandbox's own tmpfs and vanish silently — no error, no | |
| # file_delta, no deliverable. BwrapSandbox creates missing read-write | |
| # bind sources, and raises loudly if it cannot. | |
| binds: list[tuple[str, str, bool]] = [(outputs_dir, "/outputs", False)] | |
| if Path(inputs_dir).is_dir(): | |
| binds.append((inputs_dir, "/inputs", True)) | |
| # mem_limit_mb=None → the generous BwrapSandbox default. The tighter | |
| # worker-local cap is for opportunistic model-code fallbacks; container | |
| # mode IS the production execution path, where a data-analysis step | |
| # legitimately reserves more virtual memory than that cap allows. | |
| sandbox = BwrapSandbox(workspace=workdir, binds=tuple(binds)) | |
| _INNER_SANDBOXES[workdir] = sandbox | |
| return sandbox | |
| def reset_inner_sandboxes() -> None: | |
| """Tear down cached inner sandboxes (process shutdown / tests).""" | |
| with _INNER_SANDBOX_LOCK: | |
| cached = list(_INNER_SANDBOXES.values()) | |
| _INNER_SANDBOXES.clear() | |
| for sandbox in cached: | |
| with contextlib.suppress(Exception): | |
| sandbox.kill() | |
| def _container_inner_bwrap_enabled() -> bool: | |
| """Whether container mode should additionally wrap commands in bwrap. | |
| Off by default and deliberately so: production runs ``container`` WITHOUT | |
| bubblewrap (the runtime does not permit its namespace operations), and a | |
| dev box that silently added an inner namespace would exercise a different | |
| network model, /tmp, memory ceiling, and write path than production — | |
| which is exactly how the missing-boundary gap went unnoticed. | |
| """ | |
| return ( | |
| os.environ.get("FRONTIER_AGENT_CONTAINER_INNER_BWRAP", "").strip().lower() | |
| in {"1", "true", "yes", "on"} | |
| ) | |
| def container_uses_inner_bwrap() -> bool: | |
| """Whether container-mode commands actually enter the inner bwrap jail.""" | |
| return _container_inner_bwrap_enabled() and bwrap_available() | |
| def _grant_tool_access(path: Path) -> None: | |
| """Best-effort: let the unprivileged tool user own/write *path*. | |
| The harness writes deliverables as root (``sandbox_write_file``); without | |
| this the model could not later append to or replace its own file. | |
| """ | |
| identity = tool_identity_or_none() | |
| if identity is None: | |
| return | |
| with contextlib.suppress(OSError): | |
| os.chown(path, identity.uid, identity.gid) | |
| def _prepare_tool_writable(*dirs: str) -> None: | |
| """Make the shared mounts writable by the unprivileged tool user.""" | |
| identity = tool_identity_or_none() | |
| if identity is None: | |
| return | |
| host_identity = ( | |
| os.environ.get("APODEX_TOOL_HOST_IDENTITY", "").strip() == "1" | |
| ) | |
| for raw in dirs: | |
| target = Path(raw) | |
| with contextlib.suppress(OSError): | |
| target.mkdir(parents=True, exist_ok=True) | |
| owned = False | |
| if host_identity: | |
| # The Compose launcher remapped agent-tool to the invoking host | |
| # user, so handing the mount to that identity grants write access | |
| # without opening it to every other local account. Ownership is | |
| # best effort on its own: NFS with root_squash, uid=-mounted | |
| # exfat/vfat volumes and some virtiofs shares reject chown even for | |
| # root, and there the fallback below is the only thing that keeps | |
| # the mount writable at all. | |
| try: | |
| os.chown(target, identity.uid, identity.gid) | |
| owned = True | |
| except OSError as exc: | |
| logger.warning( | |
| "Cannot give %s to the tool user (%s); widening its mode " | |
| "instead so model commands can still write there.", | |
| raw, exc, | |
| ) | |
| try: | |
| # These are bind mounts of host directories — for the Compose agent | |
| # service /workspace is the user's checkout itself — so only ever | |
| # add bits. Replacing the mode would outlive the container and can | |
| # lock other host accounts out of their own repository. | |
| current = target.stat().st_mode & 0o7777 | |
| if owned: | |
| # The tool user owns it now; ensure only that the owner bits do | |
| # not deny access. Usually already true, so usually a no-op. | |
| if current & 0o700 != 0o700: | |
| target.chmod(current | 0o700) | |
| else: | |
| # Benchmark/platform containers may not have a corresponding | |
| # host identity. Retain their established single-task behavior. | |
| target.chmod(current | 0o777) | |
| except OSError as exc: | |
| logger.error( | |
| "Cannot make %s writable by the tool user (%s); model commands " | |
| "will fail to write there. Mount it read-write, or set " | |
| "%s=off to run them with the harness's own uid.", | |
| raw, exc, _TOOL_USER_ENV, | |
| ) | |
| # Both readability probes below run AS the tool user, so they must not carry the | |
| # harness environment. A child running under that uid owns its own | |
| # /proc/<pid>/environ — meaning a concurrent model command, running as the same | |
| # uid, could read the provider credentials straight out of a probe that inherited | |
| # them. That is precisely the boundary dropping the uid exists to hold. Neither | |
| # probe needs anything from the harness env: one runs `ls` through /bin/sh, the | |
| # other an absolute-path interpreter with a stdlib-only script. | |
| _PROBE_ENV = {"PATH": "/usr/bin:/bin"} | |
| def _probe_inputs_ls(inputs_dir: str) -> tuple[Any, Any] | None: | |
| """List ``inputs_dir`` AS the tool user; ``(identity, completed)`` or None. | |
| None means the question does not apply or could not be answered: no tool | |
| account (model commands run as the harness itself), no ``/inputs`` for this | |
| task, or the probe could not be spawned. | |
| This opens the directory as the unprivileged uid rather than inspecting the | |
| mode bits. Object-storage mounts (ossfs / s3fs and other FUSE drivers) are | |
| the common case here and they defeat a mode-bit check outright: without | |
| ``-o allow_other`` the kernel refuses every uid except the one that mounted | |
| the filesystem, while ``stat`` still reports a perfectly ordinary ``0755``. | |
| """ | |
| identity = tool_identity_or_none() | |
| if identity is None: | |
| return None | |
| target = Path(inputs_dir) | |
| if not target.is_dir(): | |
| return None # no /inputs for this task | |
| try: | |
| probe = subprocess.run( | |
| ["/bin/sh", "-c", f"ls -1 {shell_quote(str(target))} >/dev/null"], | |
| capture_output=True, text=True, timeout=15, | |
| user=identity.uid, group=identity.gid, extra_groups=[], | |
| env=_PROBE_ENV, | |
| ) | |
| except Exception as exc: # pragma: no cover - platform specific | |
| logger.warning("Could not probe %s readability: %s", inputs_dir, exc) | |
| return None | |
| return identity, probe | |
| def probe_inputs_readable(inputs_dir: str) -> bool | None: | |
| """Can the tool user actually read ``inputs_dir``? None = cannot tell. | |
| Callers that describe ``/inputs`` to a model must consult this first: the | |
| harness runs as root and can list files that the unprivileged tool uid then | |
| fails to open, so an ungated listing promises files that every subsequent | |
| read reports as ``exit=1 … Permission denied``. | |
| """ | |
| probed = _probe_inputs_ls(inputs_dir) | |
| if probed is None: | |
| return None | |
| _, probe = probed | |
| return probe.returncode == 0 | |
| # Opens each candidate as the tool user and echoes back the ones that worked. | |
| # Paths cross on stdin NUL-separated, never through a shell word or an argv | |
| # slot, so a filename containing a quote, a space or a metacharacter cannot | |
| # change what runs. Reads one byte rather than calling access(2): on a FUSE | |
| # mount the mode bits are not the authority (see :func:`_probe_inputs_ls`). | |
| _READABLE_FILTER_SRC = """ | |
| import sys | |
| out = [] | |
| for raw in sys.stdin.buffer.read().split(b"\\0"): | |
| if not raw: | |
| continue | |
| try: | |
| with open(raw, "rb") as fh: | |
| fh.read(1) | |
| except OSError: | |
| continue | |
| out.append(raw) | |
| sys.stdout.buffer.write(b"\\0".join(out)) | |
| """ | |
| def filter_readable_by_tool_user(paths: Sequence[str]) -> list[str] | None: | |
| """Keep only the paths the tool user can actually open. ``None`` = can't tell. | |
| :func:`probe_inputs_readable` answers this for the mount root, which is not | |
| the same question: ``ls -1 /inputs`` succeeds while a nested directory or a | |
| ``0600`` file inside it stays unreadable. The walk that produced *paths* ran | |
| as the harness (root), so without this filter a listing can still promise | |
| individual files whose every ``read_file`` returns ``Permission denied``. | |
| ``None`` means the question does not apply or could not be answered — no | |
| tool account (model commands run as the harness itself, so whatever the | |
| harness enumerated is readable), or the probe could not be spawned. Callers | |
| treat that as "keep everything", matching :func:`probe_inputs_readable`. | |
| """ | |
| if not paths: | |
| return [] | |
| identity = tool_identity_or_none() | |
| if identity is None: | |
| return None | |
| payload = b"\0".join(os.fsencode(p) for p in paths) | |
| try: | |
| probe = subprocess.run( | |
| [sys.executable, "-c", _READABLE_FILTER_SRC], | |
| input=payload, capture_output=True, timeout=30, | |
| user=identity.uid, group=identity.gid, extra_groups=[], | |
| env=_PROBE_ENV, | |
| ) | |
| except Exception as exc: | |
| logger.warning("Could not probe per-file readability: %s", exc) | |
| return None | |
| if probe.returncode != 0: | |
| logger.warning( | |
| "Per-file readability probe failed (exit=%s): %s", | |
| probe.returncode, (probe.stderr or b"").decode("utf-8", "replace").strip(), | |
| ) | |
| return None | |
| return [ | |
| os.fsdecode(raw) for raw in (probe.stdout or b"").split(b"\0") if raw | |
| ] | |
| def _warn_if_inputs_unreadable(inputs_dir: str) -> None: | |
| """Warn when the task's input files are invisible to the tool user. | |
| ``/inputs`` is a read-only mount, so this cannot be repaired from here — | |
| and the failure is otherwise silent: the model simply reports that it | |
| cannot open the files it was asked to work on. | |
| """ | |
| probed = _probe_inputs_ls(inputs_dir) | |
| if probed is None: | |
| return | |
| identity, probe = probed | |
| if probe.returncode == 0: | |
| return | |
| logger.error( | |
| "%s is not readable by the tool user %s(uid=%s): %s. Model commands " | |
| "will not be able to open the task's input files. For a FUSE/object-" | |
| "storage mount add `-o allow_other` (plus a non-restrictive " | |
| "mp_umask/file_mode); for an ordinary mount `chmod a+rX`. Or set " | |
| "%s=off to run model commands with the harness's own uid.", | |
| inputs_dir, identity.name, identity.uid, | |
| probe.stderr.strip() or f"exit {probe.returncode}", _TOOL_USER_ENV, | |
| ) | |
| class CurrentSandbox: | |
| """Sandbox facade for a workspace provisioned by the outer task container. | |
| Production runs one task per isolated container and does NOT run bubblewrap | |
| — its namespace operations are not permitted by the runtime. The container | |
| is the boundary against the host; within it, model-authored commands are | |
| separated from the harness's credentials by two measures: | |
| - a minimal, allowlisted child environment (:func:`_build_tool_env`), and | |
| - an unprivileged uid (:func:`tool_identity`), which is what actually stops | |
| ``cat /proc/<harness-pid>/environ`` — that file is owner-readable only. | |
| Isolation degrades to env-only (with a loud warning) when the image has no | |
| tool account or the harness is not root; set | |
| ``FRONTIER_AGENT_REQUIRE_TOOL_USER=1`` to make that fatal instead. An inner | |
| bubblewrap namespace is available via ``FRONTIER_AGENT_CONTAINER_INNER_BWRAP`` | |
| for hosts that do allow it, but is off by default so dev matches prod. | |
| """ | |
| def __init__(self, workdir: str | Path, *, private_tmp: bool = False) -> None: | |
| self._workdir = str(Path(workdir).resolve()) | |
| self._inner: BwrapSandbox | None = None | |
| if container_uses_inner_bwrap(): | |
| self._inner = _shared_inner_sandbox(self._workdir) | |
| self.commands = self._inner.commands | |
| self.files = self._inner.files | |
| else: | |
| identity = tool_identity() | |
| if identity is None: | |
| logger.warning( | |
| "CurrentSandbox running model commands with the harness's " | |
| "own uid: the child environment is scrubbed, but " | |
| "/proc/<harness-pid>/environ remains readable" | |
| ) | |
| else: | |
| _, outputs_dir, inputs_dir = resolve_mount_dirs() | |
| _prepare_tool_writable(self._workdir, outputs_dir) | |
| _warn_if_inputs_unreadable(inputs_dir) | |
| logger.info( | |
| "CurrentSandbox model commands drop to %s(uid=%s)", | |
| identity.name, identity.uid, | |
| ) | |
| self.commands = _CurrentCommands(self._workdir, private_tmp=private_tmp) | |
| self.sandbox_id = f"current-{os.getpid()}" | |
| logger.info("CurrentSandbox attached: workdir=%s", self._workdir) | |
| def kill(self) -> None: | |
| """No-op: the outer container owns the mounts, and the inner command | |
| sandbox is shared with every other facade on the same workspace. Use | |
| :func:`reset_inner_sandboxes` for process-wide teardown.""" | |
| return None | |
| def make_current_sandbox( | |
| workdir: str | Path, *, private_tmp: bool = False | |
| ) -> CurrentSandbox: | |
| """Create a sandbox facade for an already-provisioned workspace. | |
| ``private_tmp`` points ``TMPDIR`` at a scratch root inside *workdir*. Set | |
| it for a per-agent workspace; leave it off for the task-wide sandbox, whose | |
| ``/tmp`` several tools and skills name explicitly. | |
| """ | |
| path = Path(workdir).expanduser().resolve() | |
| if not path.is_dir(): | |
| raise FileNotFoundError(f"Current sandbox workdir does not exist: {path}") | |
| return CurrentSandbox(path, private_tmp=private_tmp) | |
| # ── Docker Sandbox (container-based, for pre-built SWE-bench images) ─── | |
| class _DockerCommands: | |
| """Command executor for DockerSandbox using ``docker exec``.""" | |
| # Sentinel emitted as stderr when the container is gone. A consumer | |
| # watching for container death can match this string to terminate | |
| # cleanly instead of burning turns against a dead container. | |
| DEAD_MSG = "Error: sandbox container has died" | |
| def __init__(self, container_id: str, *, login_shell: bool = True) -> None: | |
| self._container_id = container_id | |
| self._login_shell = login_shell | |
| def run(self, command: str, timeout: int = 60, | |
| input: str | None = None) -> _CommandResult: | |
| """Execute *command* inside the container via ``docker exec``. | |
| With ``login_shell=True`` (default) uses ``bash -l -c`` so the | |
| container's login profile (e.g. conda activation in swebench | |
| images) is sourced automatically. terminal-bench parity uses | |
| ``login_shell=False`` (plain ``bash -c``) to match harbor's exec. | |
| ``-i`` is added when stdin input is supplied. | |
| """ | |
| bash_args = ["bash", "-l", "-c"] if self._login_shell else ["bash", "-c"] | |
| try: | |
| result = subprocess.run( | |
| [ | |
| "docker", "exec", | |
| *(["-i"] if input is not None else []), | |
| self._container_id, | |
| *bash_args, command, | |
| ], | |
| capture_output=True, | |
| text=True, | |
| timeout=timeout, | |
| input=input, | |
| ) | |
| if result.returncode != 0 and "No such container" in result.stderr: | |
| return _CommandResult(stderr=self.DEAD_MSG, exit_code=1) | |
| return _CommandResult( | |
| stdout=result.stdout, | |
| stderr=result.stderr, | |
| exit_code=result.returncode, | |
| ) | |
| except subprocess.TimeoutExpired as exc: | |
| raise TimeoutError( | |
| f"Command timed out after {timeout}s" | |
| ) from exc | |
| except Exception as e: | |
| return _CommandResult(stderr=str(e), exit_code=1) | |
| class DockerSandbox: | |
| """Docker container sandbox for SWE-bench evaluation. | |
| Starts a detached container from a pre-built image; commands are | |
| executed via ``docker exec``. The container is auto-removed on | |
| ``kill()`` thanks to ``--rm``. | |
| """ | |
| # docker run can stretch into minutes under heavy concurrent load. | |
| _RUN_TIMEOUT = 300 | |
| def __init__( | |
| self, | |
| image: str, | |
| *, | |
| timeout: int = 3600, | |
| envs: dict[str, str] | None = None, | |
| login_shell: bool = True, | |
| ) -> None: | |
| cmd: list[str] = ["docker", "run", "-d", "--rm"] | |
| for k, v in (envs or {}).items(): | |
| cmd += ["-e", f"{k}={v}"] | |
| cmd += [image, "sleep", str(timeout + 60)] | |
| result = subprocess.run( | |
| cmd, capture_output=True, text=True, timeout=self._RUN_TIMEOUT, | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError( | |
| f"docker run failed: {result.stderr.strip()}" | |
| ) | |
| self._container_id = result.stdout.strip() | |
| self.sandbox_id = f"docker-{self._container_id[:12]}" | |
| self.commands = _DockerCommands(self._container_id, login_shell=login_shell) | |
| logger.info( | |
| "DockerSandbox created: container=%s image=%s", | |
| self._container_id[:12], image, | |
| ) | |
| def copy_in(self, src: str, dst: str, *, timeout: int = 120) -> None: | |
| """Copy a host path into the container via ``docker cp``. | |
| ``src`` ending in ``/.`` copies directory *contents* into ``dst`` | |
| (Docker semantics), matching harbor's ``upload_dir`` behaviour. | |
| """ | |
| result = subprocess.run( | |
| ["docker", "cp", src, f"{self._container_id}:{dst}"], | |
| capture_output=True, text=True, timeout=timeout, | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"docker cp failed: {result.stderr.strip()}") | |
| def copy_out(self, src: str, dst: str, *, timeout: int = 120) -> None: | |
| """Copy a container path out to the host via ``docker cp``. | |
| Mirror of :meth:`copy_in`. ``src`` ending in ``/.`` copies directory | |
| *contents* into ``dst`` (matches harbor's ``download_dir``). | |
| """ | |
| result = subprocess.run( | |
| ["docker", "cp", f"{self._container_id}:{src}", dst], | |
| capture_output=True, text=True, timeout=timeout, | |
| ) | |
| if result.returncode != 0: | |
| raise RuntimeError(f"docker cp (out) failed: {result.stderr.strip()}") | |
| def kill(self) -> None: | |
| """Stop and remove the Docker container.""" | |
| try: | |
| subprocess.run( | |
| ["docker", "kill", self._container_id], | |
| capture_output=True, | |
| timeout=15, | |
| ) | |
| logger.info( | |
| "DockerSandbox killed: %s", | |
| self._container_id[:12], | |
| ) | |
| except Exception as e: | |
| logger.warning("Error killing DockerSandbox: %s", e) | |
| # ── E2B Config ────────────────────────────────────────────────────────── | |
| def _get_e2b_config() -> tuple[str, str, int]: | |
| """Get E2B config — reads from FrontierAgentConfig (which loads .env) first.""" | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| cfg = get_config() | |
| if cfg.e2b_api_key: | |
| return cfg.e2b_api_key, cfg.e2b_template, cfg.e2b_timeout | |
| except Exception: | |
| pass | |
| api_key = os.environ.get("E2B_API_KEY", "") | |
| template = os.environ.get("E2B_TEMPLATE", "base") | |
| timeout = int(os.environ.get("E2B_TIMEOUT", "1800")) | |
| return api_key, template, timeout | |
| def _get_sandbox_backend() -> str: | |
| """Resolve the configured execution backend, failing closed on typos. | |
| ``local`` is retained as a compatibility alias for ``bwrap``; it no longer | |
| means an in-process host subprocess. ``container`` reuses the production | |
| task container's mounted ``/inputs`` (ro), ``/outputs`` (rw), and | |
| ``/workspace`` (rw) through :class:`CurrentSandbox`, which runs | |
| model-authored commands under an unprivileged uid rather than a namespace | |
| (production does not permit bwrap). Invalid values are errors rather than | |
| implicit backend downgrades. | |
| """ | |
| # The terminal CLI resolves its execution boundary after startup and then | |
| # exports SANDBOX_BACKEND (notably, macOS falls back from an unavailable | |
| # Docker daemon to ``native``). ``get_config()`` is process-cached and may | |
| # still contain the earlier ``auto`` value, so the live environment must | |
| # win or workflow tools can disagree with the CLI and incorrectly demand | |
| # Linux bubblewrap on macOS. | |
| backend = os.environ.get("SANDBOX_BACKEND", "").strip().lower() | |
| if not backend: | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| backend = (get_config().sandbox_backend or "").strip().lower() | |
| except Exception: | |
| pass | |
| backend = backend or "auto" | |
| if backend not in ("auto", "local", "bwrap", "e2b", "container", "native"): | |
| raise SandboxConfigurationError( | |
| f"invalid sandbox_backend={backend!r}; " | |
| "expected e2b, bwrap, local, container, native, or auto" | |
| ) | |
| return backend | |
| # ── Container-mode filesystem (production: one task = one isolated container) ── | |
| # | |
| # In ``container`` mode the surrounding task container owns three real dirs the | |
| # agents (main + every sub) share. CurrentSandbox runs model commands under an | |
| # unprivileged uid so they cannot read the harness's /proc/<pid>/environ: | |
| # /workspace — rw scratch / intermediate files (NOT delivered) | |
| # /outputs — rw final deliverables (what the frontend collects) | |
| # /inputs — ro task input files | |
| # These are fixed absolute mount points, overridable via env for local runs. | |
| _DEFAULT_WORKSPACE_DIR = "/workspace" | |
| _DEFAULT_OUTPUTS_DIR = "/outputs" | |
| _DEFAULT_INPUTS_DIR = "/inputs" | |
| #: Canonical mount point of the spill store. A sibling of the three above, NOT a | |
| #: subdirectory of ``/workspace``: the store has to sit outside every write root | |
| #: for the read-only mount to be a containment boundary rather than a remount | |
| #: that must out-order an overlapping rw bind. Deliberately parallel to | |
| #: ``/inputs`` — mounted read-only, authorized for READ only, gated on existing. | |
| _DEFAULT_SPILL_DIR = "/spill" | |
| #: Explicit override for the physical store, for a deployment that wants it on a | |
| #: specific volume. | |
| _SPILL_DIR_ENV = "APODEX_SPILL_DIR" | |
| def resolve_sandbox_mode(agent_cfg: dict[str, Any] | None = None) -> str: | |
| """Return the filesystem-isolation mode: ``container`` | ``bwrap`` | ``auto``. | |
| Precedence: | |
| 1. ``SANDBOX_BACKEND`` env / config — ``container`` → container mode; | |
| ``bwrap``/``local`` → bwrap mode; ``e2b`` → ``auto`` (E2B is a separate | |
| axis, unrelated to the container/bwrap FS split). | |
| 2. profile ``agent.sandbox_mode=bwrap`` may tighten ``auto`` to bwrap. | |
| A profile can never select ``container``: profiles (including | |
| request-supplied ``profile_inline``) are workload input, not trusted | |
| attestation that the worker is already inside a task container. | |
| 3. ``auto`` — the caller decides (bwrap if available, else fail closed). | |
| CurrentSandbox is therefore only reachable from trusted deployment | |
| configuration explicitly selecting the ``container`` backend; a | |
| model-authored profile cannot select it. Within container mode the | |
| credential boundary is the unprivileged tool uid, not a namespace — see | |
| :class:`CurrentSandbox`. | |
| """ | |
| backend = _get_sandbox_backend() | |
| if backend in ("container", "native"): | |
| return backend | |
| if backend in ("bwrap", "local"): | |
| return "bwrap" | |
| if backend == "e2b": | |
| return "auto" | |
| # backend == "auto": a workload profile may demand bwrap, but it may not | |
| # attest that the process itself is already inside a trusted container. | |
| mode = "" | |
| if agent_cfg: | |
| mode = str(agent_cfg.get("sandbox_mode") or "").strip().lower() | |
| if mode == "bwrap": | |
| return "bwrap" | |
| if mode == "container": | |
| logger.warning( | |
| "Ignoring untrusted profile sandbox_mode=container while " | |
| "SANDBOX_BACKEND is auto; configure SANDBOX_BACKEND=container " | |
| "at deployment time to attest the task-container boundary" | |
| ) | |
| return "auto" | |
| def resolve_mount_dirs() -> tuple[str, str, str]: | |
| """Return ``(workspace_dir, outputs_dir, inputs_dir)`` for container mode. | |
| Defaults to the production mount points ``/workspace``, ``/outputs``, | |
| ``/inputs``; each is overridable via ``FRONTIER_AGENT_WORKSPACE_DIR`` / | |
| ``FRONTIER_AGENT_OUTPUTS_DIR`` / ``FRONTIER_AGENT_INPUTS_DIR`` so a local run | |
| (no root) can point them at repo-relative dirs. | |
| """ | |
| ws = os.environ.get("FRONTIER_AGENT_WORKSPACE_DIR", "").strip() or _DEFAULT_WORKSPACE_DIR | |
| out = os.environ.get("FRONTIER_AGENT_OUTPUTS_DIR", "").strip() or _DEFAULT_OUTPUTS_DIR | |
| inp = os.environ.get("FRONTIER_AGENT_INPUTS_DIR", "").strip() or _DEFAULT_INPUTS_DIR | |
| return ws, out, inp | |
| def spill_root() -> Path: | |
| """Physical root of the spill store, outside every root the agent can write. | |
| Lives here rather than in ``_overflow`` because two other modules need it and | |
| ``_overflow`` imports this one: the sandbox mounts it, and ``_path_auth`` | |
| authorizes it for reads. Putting it there would make the dependency circular. | |
| Order: an explicit ``APODEX_SPILL_DIR``, then a run-scoped directory when the | |
| harness has one, then the OS temp dir — which is where codex puts the same | |
| thing (``<temp>/hook_outputs/<thread_id>``). Never the workspace: under | |
| ``native`` that is frequently the user's own repository. | |
| """ | |
| explicit = os.environ.get(_SPILL_DIR_ENV, "").strip() | |
| if explicit: | |
| return Path(explicit) | |
| run_dir = os.environ.get("APODEX_RUN_DIR", "").strip() | |
| if run_dir: | |
| return Path(run_dir) / "spill" | |
| # The uid is in the NAME so two accounts on one host do not share a store. | |
| # It is not a permission boundary and cannot be: under ``container`` the | |
| # model runs as a DIFFERENT uid than the harness and has to read these | |
| # files, so the directory must stay traversable by others (0755). What keeps | |
| # one conversation out of another's recovery files is ``_path_auth``, which | |
| # authorizes only the current scope plus the stores this process created — | |
| # a local user with their own shell is outside that model either way. | |
| return Path(tempfile.gettempdir()) / f"apodex-spill-{os.getuid()}" | |
| def is_spill_path(path: str) -> bool: | |
| """Whether ``path`` names the spill store, lexically or once resolved. | |
| The single rule the store's guards share, replacing a hardcoded ``.spill`` | |
| path component tested in four places. It keys off the real root, so a | |
| deployment that redirects the store with ``APODEX_SPILL_DIR`` — or a run | |
| directory that happens to sit inside the repository — is still covered, | |
| which a fixed directory name was not. | |
| Both the canonical mount path the model sees and the physical path are | |
| matched: a tool can be handed either. | |
| """ | |
| raw = str(path or "").strip() | |
| if not raw: | |
| return False | |
| roots = [_DEFAULT_SPILL_DIR, str(spill_root())] | |
| candidates = [os.path.normpath(raw)] | |
| with contextlib.suppress(OSError, RuntimeError): | |
| candidates.append(str(Path(raw).expanduser().resolve())) | |
| for candidate in candidates: | |
| for root in roots: | |
| normalized_root = os.path.normpath(root) | |
| if candidate == normalized_root or candidate.startswith( | |
| normalized_root + os.sep, | |
| ): | |
| return True | |
| return False | |
| def spill_path_matcher() -> Callable[[str | Path], bool]: | |
| """Resolved-once form of :func:`is_spill_path`, for per-file loops. | |
| ``is_spill_path`` re-reads the environment for the store root and resolves the | |
| candidate on every call: 35.6us per path against 2.9us here. Be honest about | |
| what that buys end to end, though — measured in-process on the same tree it is | |
| 1.01x for ``grep_search`` and 1.04x for ``glob_search``, because reading and | |
| scanning a file, or stat-ing and authorizing it, costs far more than deciding | |
| whether to skip it. The reason to prefer this form is that the callers already | |
| resolve ``task_input_matcher`` once for exactly this shape of lookup, and a | |
| per-file variant sitting under a comment that warns against per-file variants | |
| is a trap for the next reader. | |
| The resolve is narrowed, not dropped: only a symlink can put a path inside the | |
| store without saying so lexically, so that is the one case worth a syscall. | |
| ``test_a_symlink_into_the_recovery_store_stays_hidden`` pins it — the store | |
| leaks through a link without this branch. | |
| """ | |
| roots = [ | |
| os.path.normpath(_DEFAULT_SPILL_DIR), | |
| os.path.normpath(str(spill_root())), | |
| ] | |
| def _within(path: str | Path) -> bool: | |
| raw = str(path or "").strip() | |
| if not raw: | |
| return False | |
| # ``expanduser`` is pure string work, but only a leading ``~`` needs it. | |
| candidate = os.path.normpath( | |
| str(Path(raw).expanduser()) if raw.startswith("~") else raw, | |
| ) | |
| for root in roots: | |
| if candidate == root or candidate.startswith(root + os.sep): | |
| return True | |
| try: | |
| if not Path(raw).is_symlink(): | |
| return False | |
| except OSError: | |
| return False | |
| return is_spill_path(raw) | |
| return _within | |
| def resolve_runtime_path(path: str) -> str: | |
| """Map a canonical sandbox path onto the current runtime namespace. | |
| Only the mount prefix is rewritten; the rest of the path is handed back as | |
| given. Nothing here canonicalises the *result* — collapsing ``..`` against | |
| a symlinked directory would name a different file than the caller asked for. | |
| Docker, container and bwrap modes expose real ``/workspace``, ``/outputs`` | |
| and ``/inputs`` mounts, so their configured roots equal the aliases and | |
| this is a no-op. Native mode uses physical run-local directories because | |
| hosts such as macOS do not allow creating writable mount points below ``/``. | |
| Only an exact alias or a component beneath it is rewritten. Relative paths | |
| and sibling names such as ``/outputs-old`` are intentionally unchanged. | |
| """ | |
| if not path or not os.path.isabs(path): | |
| return path | |
| normalized = os.path.normpath(path) | |
| workspace, outputs, inputs = resolve_mount_dirs() | |
| for alias, root in ( | |
| (_DEFAULT_WORKSPACE_DIR, workspace), | |
| (_DEFAULT_OUTPUTS_DIR, outputs), | |
| (_DEFAULT_INPUTS_DIR, inputs), | |
| # ``/spill`` is an alias for exactly the same reason as the three above: | |
| # a tool running in THIS process is handed the canonical path the model | |
| # saw inside the sandbox, and has to reach the real directory. | |
| (_DEFAULT_SPILL_DIR, str(spill_root())), | |
| ): | |
| # ``resolve_mount_dirs`` documents repo-relative overrides. Anchoring | |
| # them keeps the substitution absolute: an absolute alias that came | |
| # back as ``outputs/report.docx`` would be resolved against whatever | |
| # cwd the sandbox process happens to have, which is not this one. | |
| normalized_root = os.path.normpath(os.path.abspath(root)) | |
| if normalized_root == alias: | |
| continue | |
| if normalized == alias: | |
| return normalized_root | |
| prefix = alias + os.sep | |
| if normalized.startswith(prefix): | |
| return os.path.join(normalized_root, normalized[len(prefix):]) | |
| return path | |
| # ── CJK Font Provisioning ────────────────────────────────────────────── | |
| # | |
| # matplotlib does not consult fontconfig: it resolves families through its own | |
| # font_manager, whose packaged default is ``font.sans-serif: DejaVu Sans`` — | |
| # zero CJK coverage. Installing Noto CJK is therefore necessary but NOT | |
| # sufficient; without an rc, 中文/日本語/한국어 labels still render as tofu | |
| # boxes (□). So we install the fonts AND write a matplotlibrc. | |
| # | |
| # The CJK family must come FIRST in each chain: matplotlib's per-glyph | |
| # fallback down font.sans-serif does not engage for these families (verified | |
| # in-image — "DejaVu Sans, Noto Sans CJK SC" still warns about missing glyphs, | |
| # the reverse order renders clean). Noto CJK carries Latin/Kana/Hangul too, so | |
| # leading with it costs no coverage. | |
| # | |
| # Keep the font chains in sync with ``docker/matplotlibrc``, which is the same | |
| # config baked into the agent images (where it is installed system-wide via | |
| # MATPLOTLIBRC and so covers container mode as well). This copy carries extra | |
| # WenQuanYi / mplfonts names for third-party E2B templates where Noto is not | |
| # guaranteed; tests/test_chart_visualization_skill.py asserts the two agree. | |
| _CJK_MATPLOTLIBRC = ( | |
| "font.family: sans-serif\n" | |
| "font.sans-serif: Noto Sans CJK SC, Noto Sans CJK JP, Noto Sans, " | |
| "WenQuanYi Zen Hei, WenQuanYi Micro Hei, Source Han Sans CN, " | |
| "PingFang SC, Heiti SC, Arial Unicode MS, " | |
| "Liberation Sans, DejaVu Sans, Arial\n" | |
| "font.serif: Noto Serif CJK SC, Noto Serif CJK JP, Noto Serif, " | |
| "Source Han Serif CN, Liberation Serif, Caladea, DejaVu Serif, " | |
| "Times New Roman\n" | |
| "font.monospace: Noto Sans Mono CJK SC, Liberation Mono, " | |
| "DejaVu Sans Mono, Courier New\n" | |
| "axes.unicode_minus: False\n" | |
| ) | |
| _CJK_REMOTE_PROVISION_SCRIPT = r""" | |
| # Best-effort CJK font install (apt-get first, mplfonts fallback). | |
| if command -v apt-get >/dev/null 2>&1; then | |
| (sudo -n apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true) | |
| (sudo -n apt-get install -y -qq --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra fonts-liberation fonts-crosextra-caladea fonts-wqy-zenhei 2>/dev/null \ | |
| || apt-get install -y -qq --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra fonts-liberation fonts-crosextra-caladea fonts-wqy-zenhei 2>/dev/null || true) | |
| fc-cache -f >/dev/null 2>&1 || true | |
| fi | |
| if ! (fc-list :lang=zh 2>/dev/null | head -n 1 | grep -q .); then | |
| python -c "from mplfonts.bin.cli import init; init()" >/dev/null 2>&1 || true | |
| fi | |
| mkdir -p "$HOME/.config/matplotlib" | |
| cat > "$HOME/.config/matplotlib/matplotlibrc" <<'__MIRORC__' | |
| __RC_PLACEHOLDER__ | |
| __MIRORC__ | |
| # Invalidate matplotlib's font cache so new TTFs are picked up on next import. | |
| rm -rf "$HOME/.cache/matplotlib" "$HOME/.matplotlib" 2>/dev/null || true | |
| true | |
| """.replace("__RC_PLACEHOLDER__", _CJK_MATPLOTLIBRC.rstrip("\n")) | |
| _PDF_CLI_PROVISION_SCRIPT = r""" | |
| # Best-effort: install poppler-utils so `pdftotext` CLI works for PDF extraction. | |
| if ! command -v pdftotext >/dev/null 2>&1; then | |
| if command -v apt-get >/dev/null 2>&1; then | |
| (sudo -n apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true) | |
| (sudo -n apt-get install -y -qq --no-install-recommends poppler-utils 2>/dev/null \ | |
| || apt-get install -y -qq --no-install-recommends poppler-utils 2>/dev/null || true) | |
| fi | |
| fi | |
| true | |
| """ | |
| def _provision_pdf_cli(sandbox: Any) -> None: | |
| """Best-effort install of poppler-utils for `pdftotext` CLI inside *sandbox*. | |
| Only runs on remote (E2B / Docker) sandboxes where apt-get is available and | |
| sudo may succeed. BwrapSandbox / CurrentSandbox use pre-installed tools. | |
| """ | |
| try: | |
| if isinstance(sandbox, (BwrapSandbox, CurrentSandbox)): | |
| return | |
| sandbox.commands.run(_PDF_CLI_PROVISION_SCRIPT, timeout=120) | |
| except Exception as exc: | |
| logger.warning("pdftotext provisioning skipped: %s", exc) | |
| def _provision_cjk_fonts(sandbox: Any) -> None: | |
| """Install CJK fonts and set matplotlib defaults inside *sandbox*. | |
| Best-effort: failures are logged, never raised. On remote sandboxes | |
| (E2B/Docker) tries ``apt-get install fonts-noto-cjk`` then falls back | |
| to the ``mplfonts`` pip package. On BwrapSandbox we only write a | |
| matplotlibrc inside its workspace; CurrentSandbox is left untouched. | |
| NB container mode never reaches this function at all — ``get_sandbox()`` | |
| returns a :class:`CurrentSandbox` directly without going through | |
| ``_create_provisioned_sandbox``. That path is covered at the image layer | |
| instead: the agent images ship ``docker/matplotlibrc`` at | |
| ``/etc/matplotlib/matplotlibrc`` with ``MATPLOTLIBRC`` pointing at it, so | |
| the defaults apply to every interpreter regardless of ``$HOME``. | |
| """ | |
| try: | |
| if isinstance(sandbox, BwrapSandbox): | |
| # Bwrap commands override HOME to /workspace, so writing | |
| # workdir/.config/matplotlib/matplotlibrc is isolated from the | |
| # user's real host config and will be picked up by matplotlib. | |
| rc_dir = Path(sandbox._workdir) / ".config" / "matplotlib" | |
| rc_dir.mkdir(parents=True, exist_ok=True) | |
| (rc_dir / "matplotlibrc").write_text( | |
| _CJK_MATPLOTLIBRC, encoding="utf-8", | |
| ) | |
| return | |
| if isinstance(sandbox, CurrentSandbox): | |
| # Container mode: the image already ships /etc/matplotlib/matplotlibrc | |
| # with MATPLOTLIBRC pointing at it, and MATPLOTLIBRC is on the tool | |
| # env allowlist, so the inner bwrap children pick it up unchanged. | |
| return | |
| sandbox.commands.run(_CJK_REMOTE_PROVISION_SCRIPT, timeout=180) | |
| except Exception as exc: | |
| logger.warning("CJK font provisioning skipped: %s", exc) | |
| # ── Sandbox Accessor ──────────────────────────────────────────────────── | |
| #: Absolute (monotonic) instant each remote sandbox's TTL has been extended to. | |
| #: ``set_timeout`` is RELATIVE to now, so without this a routine refresh would | |
| #: silently shorten a deadline a long exec had already raised — see | |
| #: :func:`_extend_sandbox_ttl`. Keyed weakly so a discarded sandbox is not | |
| #: pinned; guarded by a lock because ``_ensure_ttl_outlives_exec`` calls in via | |
| #: ``asyncio.to_thread`` while the singleton path calls in from the event loop. | |
| _TTL_DEADLINES: weakref.WeakKeyDictionary[Any, float] = weakref.WeakKeyDictionary() | |
| _TTL_LOCK = threading.Lock() | |
| def _extend_sandbox_ttl(sandbox: Any, min_seconds: float = 0.0) -> None: | |
| """Reset an E2B sandbox's time-to-live on each reuse. | |
| ``Sandbox.create(timeout=...)`` only sets the lifetime at birth — a long | |
| multi-agent run that exceeds it gets the sandbox auto-killed mid-flight, | |
| forcing a cold recreate. Calling ``set_timeout`` on every access keeps the | |
| shared singleton alive for the whole run. No-op for BwrapSandbox (no remote | |
| lifecycle, so no ``set_timeout`` attribute). | |
| ``min_seconds`` raises the floor for a single long exec. The configured | |
| ``e2b_timeout`` (1800s) is the same order as the per-command deadlines | |
| profiles now grant bash, so a command allowed to run for the full budget | |
| would otherwise race the TTL set at its own start and die as a sandbox | |
| failure instead of a clean timeout. | |
| The TTL is only ever moved LATER. ``set_timeout`` is relative to the moment | |
| it is called, so on the shared singleton a concurrent short tool call — | |
| which refreshes with no minimum — would otherwise reset a long command's | |
| extension back to the configured ``e2b_timeout``, killing the sandbox mid | |
| exec. That is not hypothetical whenever ``tool_timeout_s`` exceeds | |
| ``e2b_timeout``: the long call asks for 3660s, an ordinary call 200ms later | |
| asks for 1800s, and the command dies at half its own deadline. Tracking the | |
| absolute instant instead of the relative window makes a refresh unable to | |
| lower it. | |
| """ | |
| set_timeout = getattr(sandbox, "set_timeout", None) | |
| if set_timeout is None: | |
| return | |
| try: | |
| _, _, configured = _get_e2b_config() | |
| except Exception as exc: # best-effort; reuse still works without it | |
| logger.debug("Sandbox TTL extension skipped: %s", exc) | |
| return | |
| now = time.monotonic() | |
| # The window is a promise of "at least this long", so it is passed through | |
| # untouched on the common path rather than recovered as ``deadline - now``, | |
| # and rounded UP (never truncated) when the clamp below rewrites it. CI | |
| # once observed a 1799 out of a configured 1800 here, which the previous | |
| # ``int(deadline - now)`` form could produce but this one cannot. | |
| window = max(float(configured), float(min_seconds)) | |
| deadline = now + window | |
| with _TTL_LOCK: | |
| try: | |
| existing = _TTL_DEADLINES.get(sandbox) | |
| if existing is not None and existing > deadline: | |
| deadline = existing | |
| window = float(math.ceil(deadline - now)) | |
| _TTL_DEADLINES[sandbox] = deadline | |
| except TypeError: | |
| # Not weak-referenceable; fall back to the un-tracked behaviour | |
| # rather than losing the refresh entirely. | |
| pass | |
| try: | |
| set_timeout(max(math.ceil(window), 1)) | |
| except Exception as exc: # best-effort; reuse still works without it | |
| logger.debug("Sandbox TTL extension skipped: %s", exc) | |
| # Pre-installed scientific stack for fresh E2B sandboxes. Kept as a module | |
| # constant so the single-shared-singleton path (``get_sandbox``) and the | |
| # per-sub-agent ``SandboxPool`` install the exact same set. | |
| _E2B_PIP_PACKAGES = ( | |
| "pip install matplotlib numpy pandas seaborn scipy " | |
| "Pillow markdown sympy plotly tabulate mplfonts " | |
| "beautifulsoup4 requests openpyxl " | |
| "pypdf PyMuPDF pdfplumber -q --quiet" | |
| ) | |
| def _create_provisioned_sandbox( | |
| *, | |
| use_e2b: bool, | |
| api_key: str = "", | |
| template: str = "base", | |
| timeout: int = 1800, | |
| ) -> Any: | |
| """Create and provision a fresh isolated sandbox (E2B or bubblewrap). | |
| Shared by the single-shared-singleton path (:func:`get_sandbox`) and the | |
| per-sub-agent :class:`SandboxPool` so both get an identical environment: | |
| pre-installed scientific stack (E2B only), CJK fonts + matplotlibrc, and | |
| the canonical ``/tmp/agent-outputs`` dir. | |
| Blocking (E2B create + pip install can take ~minutes); callers in async | |
| contexts must wrap in ``asyncio.to_thread``. | |
| """ | |
| if use_e2b: | |
| from e2b_code_interpreter import Sandbox | |
| logger.info( | |
| "Creating E2B sandbox (template=%s, timeout=%ds)", template, timeout, | |
| ) | |
| sandbox = Sandbox.create( | |
| template=template, timeout=timeout, api_key=api_key, | |
| ) | |
| try: | |
| logger.info("E2B sandbox created: %s", sandbox.sandbox_id) | |
| # E2B bills by sandbox lifetime, not per execution. | |
| # Open a wall-clock span keyed by sandbox_id; the kill paths | |
| # close it, and still-open spans contribute elapsed-so-far at | |
| # snapshot time (covers wall_deadline kills mid-flight). The TTL | |
| # gauge lets consumers bound the unobservable post-exit tail: | |
| # ``true_bill ≤ sandbox_seconds + spans_open × sandbox_ttl_seconds``. | |
| record_api_request("e2b", requests=0, sandboxes_created=1) | |
| open_meter_span("e2b", str(sandbox.sandbox_id)) | |
| set_meter_gauge("e2b", "sandbox_ttl_seconds", timeout) | |
| sandbox.commands.run(_E2B_PIP_PACKAGES, timeout=180) | |
| logger.info("E2B sandbox pre-packages installed") | |
| _provision_pdf_cli(sandbox) | |
| except Exception: | |
| # A sandbox may exist (and bill) even though provisioning failed. | |
| # Reap it before auto mode creates the bwrap fallback. | |
| _safe_kill(sandbox) | |
| raise | |
| else: | |
| logger.info("Creating local BwrapSandbox") | |
| # This is generic model code, not the large-document file sandbox. | |
| # Keep the tighter local limit so an E2B outage cannot become a worker | |
| # OOM incident during fallback. | |
| sandbox = BwrapSandbox(mem_limit_mb=_local_mem_limit_mb()) | |
| probe = sandbox.commands.run("true", timeout=10) | |
| if probe.exit_code != 0: | |
| sandbox.kill() | |
| raise SandboxUnavailableError( | |
| "bubblewrap sandbox failed its execution probe: " | |
| f"{probe.stderr.strip() or f'exit code {probe.exit_code}'}" | |
| ) | |
| # Pre-provision CJK fonts + matplotlibrc so plots render Chinese/Japanese | |
| # labels instead of tofu boxes. Best-effort; failures are logged. | |
| _provision_cjk_fonts(sandbox) | |
| # Pre-create the canonical outputs dir so the LLM's first `ls` of it | |
| # succeeds and matches what the system prompt promises. Cheap & idempotent. | |
| try: | |
| sandbox.commands.run( | |
| "mkdir -p /tmp/agent-outputs && chmod 755 /tmp/agent-outputs", | |
| timeout=10, | |
| ) | |
| except Exception as e: | |
| logger.warning("Failed to pre-create outputs dir in sandbox: %s", e) | |
| return sandbox | |
| def _resolve_use_e2b() -> tuple[bool, str, str, int]: | |
| """Decide whether to use E2B and return ``(use_e2b, api_key, template, | |
| timeout)``. Centralises the backend-precedence logic so the singleton | |
| path and the pool agree on when E2B is in play.""" | |
| backend = _get_sandbox_backend() | |
| api_key, template, timeout = _get_e2b_config() | |
| if backend in ("local", "bwrap", "container", "native"): | |
| # The outer one-task worker container is the selected sandbox. An E2B | |
| # credential may still be present for unrelated services, but it must | |
| # not silently move sub-agents off-box: those VMs have none of this | |
| # task's /inputs or /outputs mounts. | |
| use_e2b = False | |
| elif backend == "e2b": | |
| if not api_key: | |
| raise SandboxConfigurationError( | |
| "sandbox_backend=e2b requires E2B_API_KEY; refusing to execute " | |
| "model-authored code without an isolated backend" | |
| ) | |
| use_e2b = True | |
| else: # auto | |
| # ``auto`` chooses only isolated backends: E2B when configured, | |
| # otherwise bubblewrap. Bwrap creation still fails closed if unusable. | |
| # | |
| # But a key sitting in ``.env`` is not consent to ship the user's files | |
| # to a third party. Under the ``local`` profile ``auto`` therefore stays | |
| # on this machine, and reaching E2B takes an explicit | |
| # ``SANDBOX_BACKEND=e2b``. | |
| # | |
| # This was not hypothetical: with a key present, agent-team sub-agents' | |
| # ``create_file`` calls went to a cloud sandbox on what was supposed to | |
| # be a local benchmark run, and two of five questions then sat silent | |
| # for two hours after an E2B keepalive — invisible until the 8.5h | |
| # per-question timeout. The CLI had already worked around it by deleting | |
| # E2B_API_KEY from its own environment; fixing it here means every | |
| # caller gets the same guarantee instead of each remembering to. | |
| use_e2b = bool(api_key) and _sandbox_profile() == _PROFILE_SERVICE | |
| if api_key and not use_e2b: | |
| logger.info( | |
| "E2B key present but ignored: SANDBOX_PROFILE=%s keeps execution " | |
| "on this machine. Set SANDBOX_BACKEND=e2b to use the cloud " | |
| "sandbox deliberately.", | |
| _sandbox_profile(), | |
| ) | |
| return use_e2b, api_key, template, timeout | |
| def _live_shared_sandbox() -> Any: | |
| """Return the shared singleton if it's alive (and refresh its TTL), else | |
| clear it and return None. Caller must hold or not need the creation lock.""" | |
| global _sandbox | |
| if _sandbox is None: | |
| return None | |
| try: | |
| result = _sandbox.commands.run("echo ok", timeout=5) | |
| if getattr(result, "exit_code", 0) != 0: | |
| raise SandboxUnavailableError( | |
| f"sandbox health check failed with exit code {result.exit_code}" | |
| ) | |
| _extend_sandbox_ttl(_sandbox) | |
| return _sandbox | |
| except Exception: | |
| logger.warning("Sandbox connection lost, creating new one") | |
| _sandbox = None | |
| return None | |
| def get_sandbox() -> Any: | |
| """Get a sandbox instance for the current context. | |
| Priority: | |
| 1. Per-task sandbox (set via set_task_sandbox) — for SWE benchmark isolation | |
| 2. Shared singleton (E2B or BwrapSandbox) — for research tasks | |
| """ | |
| # Check per-task override first | |
| task_sb = _task_sandbox.get(None) | |
| if task_sb is not None: | |
| return task_sb | |
| global _sandbox | |
| # Fast path: reuse the live shared singleton without taking the lock. | |
| live = _live_shared_sandbox() | |
| if live is not None: | |
| return live | |
| # Slow path: serialize creation so a burst of concurrent sub-agents shares | |
| # ONE sandbox instead of each spinning up (and leaking) its own. | |
| with _sandbox_lock: | |
| # Double-check — another thread may have created it while we waited. | |
| live = _live_shared_sandbox() | |
| if live is not None: | |
| return live | |
| backend = _get_sandbox_backend() | |
| # Container filesystem mode: attach the shared mounts; CurrentSandbox | |
| # drops model commands to the unprivileged tool uid. | |
| # The node normally sets this explicitly per task. | |
| if backend in ("container", "native"): | |
| workspace_dir = resolve_mount_dirs()[0] | |
| Path(workspace_dir).mkdir(parents=True, exist_ok=True) | |
| _sandbox = CurrentSandbox(workspace_dir) | |
| return _sandbox | |
| use_e2b, api_key, template, timeout = _resolve_use_e2b() | |
| try: | |
| _sandbox = _create_provisioned_sandbox( | |
| use_e2b=use_e2b, | |
| api_key=api_key, | |
| template=template, | |
| timeout=timeout, | |
| ) | |
| except Exception as exc: | |
| # ``auto`` is the availability-oriented policy: E2B first, then a | |
| # real Linux bubblewrap sandbox. Explicit ``e2b`` remains strict so | |
| # operators can require off-host execution where policy demands it. | |
| if backend == "auto" and use_e2b: | |
| logger.warning( | |
| "E2B sandbox creation failed; trying isolated bubblewrap " | |
| "fallback: %s", exc, | |
| ) | |
| try: | |
| _sandbox = _create_provisioned_sandbox(use_e2b=False) | |
| except Exception as bwrap_exc: | |
| raise SandboxUnavailableError( | |
| "E2B sandbox creation failed and the Linux bubblewrap " | |
| f"fallback is unavailable: E2B={exc}; bwrap={bwrap_exc}" | |
| ) from bwrap_exc | |
| else: | |
| backend_name = "E2B" if use_e2b else "bubblewrap" | |
| raise SandboxUnavailableError( | |
| f"{backend_name} sandbox creation failed; no unisolated " | |
| f"host fallback is permitted: {exc}" | |
| ) from exc | |
| return _sandbox | |
| def current_local_workspace() -> str: | |
| """Return the host workspace mounted at ``/workspace`` for this task. | |
| Unlike :func:`resolve_mount_dirs`, this respects a per-agent BwrapSandbox, | |
| whose private host worktree can differ from the process-wide mount config. | |
| Remote sandboxes intentionally return an empty string. | |
| """ | |
| sandbox = _task_sandbox.get(None) | |
| if isinstance(sandbox, (BwrapSandbox, CurrentSandbox)): | |
| return sandbox._workdir | |
| return "" | |
| def close_sandbox() -> None: | |
| """Close the shared sandbox. Called on application shutdown.""" | |
| global _sandbox | |
| if _sandbox is not None: | |
| _close_e2b_meter_span(_sandbox) | |
| try: | |
| _sandbox.kill() | |
| logger.info("Sandbox closed") | |
| except Exception as e: | |
| logger.warning("Error closing sandbox: %s", e) | |
| _sandbox = None | |
| def get_existing_sandbox() -> Any: | |
| """Return the current sandbox if one exists, without creating a new one.""" | |
| task_sb = _task_sandbox.get(None) | |
| if task_sb is not None: | |
| return task_sb | |
| return _sandbox | |
| def sandbox_available() -> bool: | |
| """Return whether the configured isolated backend is currently usable.""" | |
| try: | |
| backend = _get_sandbox_backend() | |
| if backend in ("container", "native"): | |
| # The mounted workspace is always usable; the credential boundary | |
| # is the tool uid, not a namespace, and its absence degrades | |
| # (loudly) rather than making the sandbox unavailable. | |
| return True | |
| api_key, _, _ = _get_e2b_config() | |
| if backend == "e2b": | |
| return bool(api_key) | |
| if backend in ("local", "bwrap"): | |
| return bwrap_available() | |
| return bool(api_key) or bwrap_available() | |
| except SandboxError: | |
| return False | |
| def is_e2b_available() -> bool: | |
| """Check if E2B cloud sandbox would be used (API key set and not forced local).""" | |
| if _get_sandbox_backend() in ("local", "bwrap"): | |
| return False | |
| api_key, _, _ = _get_e2b_config() | |
| return bool(api_key) | |
| def shell_quote(s: str) -> str: | |
| """Simple POSIX shell quoting for sandbox commands.""" | |
| return "'" + s.replace("'", "'\\''") + "'" | |
| def sandbox_write_file( | |
| sandbox: Any, path: str, content: str, *, mode: str = "w", | |
| ) -> tuple[bool, str]: | |
| """Write *content* to *path* inside *sandbox*. Returns (success, error_message). | |
| Args: | |
| mode: "w" (overwrite, default) or "a" (append). | |
| Dispatch order: | |
| 1. CurrentSandbox — direct pathlib write onto the container's real mounts | |
| 2. E2B — native files.write() API (append not supported, falls through) | |
| 3. DockerSandbox — docker cp (overwrite only; append via fallback) | |
| 4. Fallback — base64 via python3 inside the sandbox | |
| """ | |
| # ── Current container mounts ───────────────────────────────────────── | |
| # FIRST, ahead of the generic ``files`` branch: CurrentSandbox exposes the | |
| # inner bwrap file API, whose write marshals content as base64 inside a | |
| # single ``python3 -c`` argv. Linux caps one argv entry at MAX_ARG_STRLEN | |
| # (128 KB), so routing deliverables through it fails with E2BIG somewhere | |
| # around 96 KB of content — and container mode has no /tmp fallback, so the | |
| # write becomes a hard error. The harness process already has the mount | |
| # bound read-write and the path has passed ``_path_auth``; writing it | |
| # directly is both unlimited and cheaper (no bwrap + python3 spawn). | |
| if isinstance(sandbox, CurrentSandbox): | |
| try: | |
| p = Path(path) | |
| new_parent = not p.parent.exists() | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| with open(p, mode, encoding="utf-8") as fh: | |
| fh.write(content) | |
| # Written as root; hand it to the tool user so the model's own | |
| # bash can still append to, replace, or delete its deliverable. | |
| _grant_tool_access(p) | |
| if new_parent: | |
| _prepare_tool_writable(str(p.parent)) | |
| return True, "" | |
| except Exception as e: | |
| return False, str(e) | |
| # ── E2B ────────────────────────────────────────────────────────────── | |
| if mode == "w" and hasattr(sandbox, "files") and hasattr(sandbox.files, "write"): | |
| try: | |
| sandbox.files.write(path, content) | |
| return True, "" | |
| except Exception as e: | |
| return False, str(e) | |
| # ── Docker ─────────────────────────────────────────────────────────── | |
| if mode == "w" and isinstance(sandbox, DockerSandbox): | |
| parent = os.path.dirname(path) | |
| if parent: | |
| sandbox.commands.run(f"mkdir -p {parent}", timeout=10) | |
| with tempfile.NamedTemporaryFile( | |
| mode="w", encoding="utf-8", delete=False, suffix=".tmp", | |
| ) as f: | |
| f.write(content) | |
| tmp = f.name | |
| try: | |
| r = subprocess.run( | |
| ["docker", "cp", tmp, f"{sandbox._container_id}:{path}"], | |
| capture_output=True, text=True, timeout=30, | |
| ) | |
| return (r.returncode == 0, "" if r.returncode == 0 else r.stderr.strip()) | |
| except Exception as e: | |
| return False, str(e) | |
| finally: | |
| with contextlib.suppress(OSError): | |
| os.unlink(tmp) | |
| # ── Fallback: base64 via python3 inside the sandbox ────────────────── | |
| import base64 | |
| b64c = base64.b64encode(content.encode()).decode() | |
| b64p = base64.b64encode(path.encode()).decode() | |
| open_mode = "ab" if mode == "a" else "wb" | |
| cmd = ( | |
| f"python3 -c \"import base64, os; p = base64.b64decode('{b64p}').decode(); " | |
| f"os.makedirs(os.path.dirname(p) or '.', exist_ok=True); " | |
| f"open(p, '{open_mode}').write(base64.b64decode('{b64c}'))\"" | |
| ) | |
| r = sandbox.commands.run(cmd, timeout=30) | |
| return (r.exit_code == 0, "" if r.exit_code == 0 else r.stderr.strip() or "write failed") | |
| # ── Async wrappers ────────────────────────────────────────────────────── | |
| # The E2B SDK is synchronous (blocking httpx); BwrapSandbox / DockerSandbox | |
| # use ``subprocess.run``. Calling these directly inside an ``async def`` | |
| # tool freezes the event loop — these helpers offload the blocking call | |
| # to a worker thread so a stalled sandbox cannot starve sibling tools or | |
| # parallel sub-agents in the agent-team runtime. | |
| async def aget_sandbox() -> Any: | |
| """Async ``get_sandbox`` — never blocks the event loop.""" | |
| import asyncio | |
| return await asyncio.to_thread(get_sandbox) | |
| # Bounds how many local bwrap execs run at once across the whole process. | |
| # Each local exec consumes real worker RAM (unlike E2B, which offloads | |
| # to the cloud), so without a gate ~dozens of concurrent sub-agents | |
| # running code simultaneously could exhaust the container. Created lazily so | |
| # it binds to the running loop. E2B/Docker execs are not gated (they run | |
| # off-box). 0 = unlimited. | |
| _local_exec_sem: Any = None | |
| def _local_concurrency() -> int: | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| return int(get_config().sandbox_local_max_concurrency) | |
| except Exception: | |
| try: | |
| return int(os.environ.get("SANDBOX_LOCAL_MAX_CONCURRENCY", "2")) | |
| except ValueError: | |
| return 2 | |
| def _get_local_exec_sem(asyncio_mod: Any) -> Any: | |
| global _local_exec_sem | |
| if _local_exec_sem is None: | |
| n = _local_concurrency() | |
| _local_exec_sem = asyncio_mod.Semaphore(n if n > 0 else 1_000_000) | |
| return _local_exec_sem | |
| async def _ensure_ttl_outlives_exec(sandbox: Any, timeout: int) -> None: | |
| """Make a remote sandbox's TTL outlast a single long exec. | |
| ``aget_sandbox`` already refreshes the TTL to ``e2b_timeout`` on every | |
| access, which used to dominate every per-command deadline. It no longer | |
| does: profiles grant bash up to ``tool_timeout_s`` (1800s in the TUI | |
| profile), the same order as the default TTL, so a command permitted to use | |
| its whole budget would race the TTL set at its own start and come back as a | |
| sandbox death rather than a clean timeout. | |
| Only reached when the exec needs MORE than the configured TTL, so ordinary | |
| short commands pay no extra round-trip, and never for local backends (no | |
| ``set_timeout``). Runs off the event loop because ``set_timeout`` is a | |
| blocking API call. | |
| """ | |
| if getattr(sandbox, "set_timeout", None) is None: | |
| return | |
| needed = timeout + 60 # covers the post-timeout kill and output drain | |
| try: | |
| _, _, configured = _get_e2b_config() | |
| except Exception: # pragma: no cover - config failure is handled downstream | |
| return | |
| if needed <= configured: | |
| return | |
| import asyncio | |
| await asyncio.to_thread(_extend_sandbox_ttl, sandbox, needed) | |
| async def arun_sandbox_cmd( | |
| sandbox: Any, command: str, *, timeout: int, input: str | None = None, | |
| allow_net: bool = False, env_allow: tuple[str, ...] = (), | |
| ) -> Any: | |
| """Async ``sandbox.commands.run`` — never blocks the event loop. | |
| ``input`` (when set) is fed to the command's stdin — used by read_file to | |
| pipe the ~95KB reader bundle in (echoing it into argv overflows execve's | |
| 128KB single-arg limit). Only passed through when set, so callers that | |
| don't need stdin (bash/grep) keep the original signature. | |
| ``allow_net=True``: used by explicitly network-backed capabilities | |
| (read_file OCR/VISION, controlled downloads, and bash research). It is | |
| forwarded only when the backend supports the parameter (BwrapCommands); | |
| E2B/current-container backends already use their surrounding network. | |
| ``env_allow`` exposes trusted, per-call environment-name prefixes only to | |
| backends that support the parameter. Never pass model-controlled values. | |
| BwrapSandbox execs are throttled by a process-wide semaphore | |
| so dozens of concurrent sub-agents can't collectively OOM the worker. | |
| Cloud/Docker sandboxes run off-box and are not gated. | |
| E2B's SDK *raises* on non-zero exits (``CommandExitException``, a | |
| ``CommandResult`` subclass carrying stdout/stderr/exit_code) instead of | |
| returning a result. Without normalisation every failing remote exec | |
| surfaced to callers as ``Error: CommandExitException: ...`` — dropping | |
| stdout and dead-coding their exit-code handling (the 124/137 timeout | |
| message in ``run_python_code``, the ``[Exit code N]`` prefix in | |
| ``bash``). Convert it back to a ``_CommandResult``. E2B's | |
| ``TimeoutException`` likewise isn't a ``TimeoutError`` subclass — re-raise | |
| as one so callers' timeout branches fire. Both are matched by class name | |
| to keep ``e2b`` an optional import here. | |
| """ | |
| import asyncio | |
| await _ensure_ttl_outlives_exec(sandbox, timeout) | |
| # Only forward ``input`` when set — E2B's commands.run has no stdin param, | |
| # so unconditional passthrough would break the remote path. | |
| # Annotated: inferred from the first entry this became dict[str, int], | |
| # which then rejected the str / bool / tuple capabilities added below. | |
| kw: dict[str, Any] = {"timeout": timeout} | |
| if input is not None: | |
| kw["input"] = input | |
| # Forward explicit network/environment capabilities only to backends that | |
| # support them. | |
| optional: dict[str, Any] = {} | |
| if allow_net: | |
| optional["allow_net"] = True | |
| if env_allow: | |
| optional["env_allow"] = env_allow | |
| if optional: | |
| try: | |
| import inspect | |
| supported = inspect.signature(sandbox.commands.run).parameters | |
| kw.update({key: value for key, value in optional.items() if key in supported}) | |
| except (TypeError, ValueError): | |
| pass | |
| if isinstance(sandbox, BwrapSandbox): | |
| async with _get_local_exec_sem(asyncio): | |
| return await asyncio.to_thread( | |
| sandbox.commands.run, command, **kw, | |
| ) | |
| try: | |
| return await asyncio.to_thread( | |
| sandbox.commands.run, command, **kw, | |
| ) | |
| except Exception as exc: | |
| exit_code = getattr(exc, "exit_code", None) | |
| if type(exc).__name__ == "CommandExitException" and isinstance(exit_code, int): | |
| return _CommandResult( | |
| stdout=getattr(exc, "stdout", "") or "", | |
| stderr=getattr(exc, "stderr", "") or "", | |
| exit_code=exit_code, | |
| ) | |
| if type(exc).__name__ == "TimeoutException": | |
| raise TimeoutError(str(exc)) from exc | |
| raise | |
| # ── Per-sub-agent E2B sandbox pool ───────────────────────────────────── | |
| # Each sub-agent leases ONE sandbox for its whole loop lifetime (so its | |
| # tools share a consistent filesystem). Different sub-agents get different | |
| # E2B VMs. In auto mode, pool saturation and creation failures use bounded | |
| # bwrap isolation; they never downgrade to host execution. | |
| def _sandbox_is_healthy(sandbox: Any) -> bool: | |
| try: | |
| result = sandbox.commands.run("echo ok", timeout=5) | |
| return getattr(result, "exit_code", 0) == 0 | |
| except Exception: | |
| return False | |
| def _reset_e2b_workspace(sandbox: Any) -> bool: | |
| """Wipe a reused E2B sandbox's transient working files so the next lessee | |
| starts clean. Keeps the sandbox + its installed packages for reuse. | |
| Targets exactly what the tools write: ``run_python_code`` scripts | |
| (``/tmp/exec_*.py``) and the canonical outputs dir. Best-effort.""" | |
| try: | |
| sandbox.commands.run( | |
| "rm -rf /tmp/exec_*.py /tmp/agent-outputs/* 2>/dev/null; " | |
| "mkdir -p /tmp/agent-outputs", | |
| timeout=15, | |
| ) | |
| return True | |
| except Exception as exc: | |
| logger.warning("E2B workspace reset failed: %s", exc) | |
| return False | |
| def _safe_kill(sandbox: Any) -> None: | |
| _close_e2b_meter_span(sandbox) | |
| try: | |
| sandbox.kill() | |
| except Exception as exc: | |
| logger.debug("sandbox kill failed: %s", exc) | |
| def is_e2b_sandbox(sandbox: Any) -> bool: | |
| """True only for real E2B SDK sandboxes (the ones that bill). | |
| Every facade here carries a ``sandbox_id`` (``local-*`` / | |
| ``current-*`` / ``docker-*``), so attribute presence can't | |
| discriminate — go by the implementing module instead. | |
| """ | |
| try: | |
| return type(sandbox).__module__.startswith("e2b") | |
| except Exception: | |
| return False | |
| def _close_e2b_meter_span(sandbox: Any) -> None: | |
| """Fold an E2B sandbox's lifetime into ``external_apis.e2b.sandbox_seconds``. | |
| Bwrap/Current/Docker facades don't bill anyone → no-op. Idempotent: | |
| the meter pops the span on close. | |
| """ | |
| if not is_e2b_sandbox(sandbox): | |
| return | |
| sandbox_id = getattr(sandbox, "sandbox_id", None) | |
| if sandbox_id: | |
| close_meter_span("e2b", str(sandbox_id)) | |
| class SandboxPool: | |
| """Bounded E2B pool with an optional isolated bubblewrap fallback. | |
| ``lease`` returns ``(sandbox, is_e2b)`` and never waits for E2B past | |
| ``lease_timeout_s``. In ``auto`` mode, pool exhaustion and E2B creation | |
| failures fall back to a fresh Linux BwrapSandbox. Bwrap creation and tool | |
| execution share the process-wide local concurrency gate. If bubblewrap is | |
| unavailable too, leasing fails closed; model code never runs on the host. | |
| Invariant: live E2B sandboxes (idle + leased) never exceed ``size`` — a new | |
| one is only created while holding a semaphore slot and only when no idle | |
| one can be reused. | |
| """ | |
| def __init__( | |
| self, | |
| *, | |
| size: int, | |
| lease_timeout_s: float, | |
| api_key: str, | |
| template: str, | |
| e2b_timeout: int, | |
| allow_bwrap_fallback: bool = True, | |
| ) -> None: | |
| self._size = max(1, size) | |
| self._lease_timeout_s = lease_timeout_s | |
| self._api_key = api_key | |
| self._template = template | |
| self._e2b_timeout = e2b_timeout | |
| self._allow_bwrap_fallback = allow_bwrap_fallback | |
| self._sem: Any = None # asyncio.Semaphore, lazily bound to the loop | |
| self._lock: Any = None # asyncio.Lock guarding _idle / _created | |
| self._idle: list[Any] = [] | |
| self._created = 0 # live E2B count (idle + leased) | |
| def _ensure_primitives(self) -> None: | |
| import asyncio | |
| if self._sem is None: | |
| self._sem = asyncio.Semaphore(self._size) | |
| self._lock = asyncio.Lock() | |
| async def lease(self, label: str = "") -> tuple[Any, bool]: | |
| """Lease E2B, or isolated bwrap when ``auto`` fallback is enabled.""" | |
| import asyncio | |
| self._ensure_primitives() | |
| try: | |
| await asyncio.wait_for( | |
| self._sem.acquire(), timeout=self._lease_timeout_s, | |
| ) | |
| except TimeoutError as exc: | |
| capacity_exc = SandboxCapacityError( | |
| f"E2B sandbox pool is full (size={self._size}); " | |
| f"lease timed out for {label or 'sub-agent'} after " | |
| f"{self._lease_timeout_s}s" | |
| ) | |
| if not self._allow_bwrap_fallback: | |
| raise capacity_exc from exc | |
| return await self._lease_bwrap(label, capacity_exc) | |
| try: | |
| sandbox = await self._reuse_or_create(label) | |
| except Exception as exc: | |
| self._sem.release() | |
| if not self._allow_bwrap_fallback: | |
| raise | |
| return await self._lease_bwrap(label, exc) | |
| return sandbox, True | |
| async def _lease_bwrap( | |
| self, label: str, e2b_exc: Exception, | |
| ) -> tuple[Any, bool]: | |
| """Create a bounded local fallback, preserving both failure causes.""" | |
| import asyncio | |
| logger.warning( | |
| "E2B unavailable for %s; trying isolated bubblewrap fallback: %s", | |
| label or "sub-agent", e2b_exc, | |
| ) | |
| try: | |
| # Provisioning itself executes a few commands. Put it behind the | |
| # same gate as subsequent local tool calls so an E2B outage cannot | |
| # cause an unbounded burst of worker-local processes. | |
| async with _get_local_exec_sem(asyncio): | |
| sandbox = await asyncio.to_thread( | |
| _create_provisioned_sandbox, use_e2b=False, | |
| ) | |
| except Exception as bwrap_exc: | |
| raise SandboxUnavailableError( | |
| f"E2B unavailable for {label or 'sub-agent'} and the Linux " | |
| "bubblewrap fallback also failed: " | |
| f"E2B={e2b_exc}; bwrap={bwrap_exc}" | |
| ) from bwrap_exc | |
| return sandbox, False | |
| async def _reuse_or_create(self, label: str) -> Any: | |
| import asyncio | |
| # Reuse an idle healthy sandbox if one exists. | |
| async with self._lock: | |
| while self._idle: | |
| cand = self._idle.pop() | |
| if await asyncio.to_thread(_sandbox_is_healthy, cand): | |
| return cand | |
| # dead → drop and keep looking | |
| await asyncio.to_thread(_safe_kill, cand) | |
| self._created -= 1 | |
| self._created += 1 # reserve the create slot under the lock | |
| try: | |
| return await asyncio.to_thread( | |
| _create_provisioned_sandbox, | |
| use_e2b=True, | |
| api_key=self._api_key, | |
| template=self._template, | |
| timeout=self._e2b_timeout, | |
| ) | |
| except Exception as exc: | |
| async with self._lock: | |
| self._created -= 1 | |
| raise SandboxUnavailableError( | |
| f"E2B sandbox creation failed for {label or 'sub-agent'}: {exc}" | |
| ) from exc | |
| async def release(self, sandbox: Any, is_e2b: bool) -> None: | |
| import asyncio | |
| if not is_e2b: | |
| # Local fallbacks are intentionally not pooled: their private | |
| # workspace is deleted at the end of this one sub-agent loop. | |
| await asyncio.to_thread(_safe_kill, sandbox) | |
| return | |
| if await asyncio.to_thread(_reset_e2b_workspace, sandbox): | |
| _extend_sandbox_ttl(sandbox) | |
| async with self._lock: | |
| self._idle.append(sandbox) | |
| else: | |
| await asyncio.to_thread(_safe_kill, sandbox) | |
| async with self._lock: | |
| self._created -= 1 | |
| self._sem.release() | |
| async def close(self) -> None: | |
| """Kill all idle sandboxes (e.g. on task teardown).""" | |
| import asyncio | |
| if self._lock is None: | |
| # Async primitives are lazily bound on first ``lease()`` — | |
| # a pool that was constructed but never leased has nothing | |
| # idle to kill (and ``async with None`` would raise). | |
| return | |
| async with self._lock: | |
| idle, self._idle = self._idle, [] | |
| self._created -= len(idle) | |
| for sandbox in idle: | |
| await asyncio.to_thread(_safe_kill, sandbox) | |
| _sandbox_pool: SandboxPool | None = None | |
| _sandbox_pool_lock = threading.Lock() | |
| def _pool_settings() -> tuple[int, float]: | |
| """``(size, lease_timeout_s)`` for the sub-agent pool.""" | |
| try: | |
| from frontier_agent.infra.config import get_config | |
| cfg = get_config() | |
| return ( | |
| int(getattr(cfg, "e2b_pool_size", 20)), | |
| float(getattr(cfg, "e2b_pool_lease_timeout_s", 5.0)), | |
| ) | |
| except Exception: | |
| return 20, 5.0 | |
| def subagent_pool_enabled() -> bool: | |
| """Will each sub-agent lease its OWN sandbox rather than share this process's? | |
| Answers a question callers need *before* any sandbox exists: a leased | |
| sandbox is a separate machine, so it carries none of this container's | |
| mounts. Anything that describes ``/inputs`` to a sub-agent has to check | |
| this first, or it promises files that sub-agent cannot open. | |
| Kept next to :func:`get_sandbox_pool`, which now shares this predicate, so | |
| the two cannot drift — note that ``e2b_pool_size <= 0`` disables pooling | |
| just as effectively as having no E2B key. | |
| """ | |
| if not _resolve_use_e2b()[0]: | |
| return False | |
| return _pool_settings()[0] > 0 | |
| def get_sandbox_pool() -> SandboxPool | None: | |
| """Return the process-wide per-sub-agent pool, or ``None`` when pooling is | |
| disabled. | |
| Disabled when E2B isn't in play (backend=container/local/bwrap, or ``auto`` | |
| without a key) or ``e2b_pool_size <= 0``. Those callers use the configured | |
| isolated singleton; no host-process fallback exists. | |
| """ | |
| global _sandbox_pool | |
| backend = _get_sandbox_backend() | |
| use_e2b, api_key, template, e2b_timeout = _resolve_use_e2b() | |
| if not use_e2b: | |
| return None | |
| size, lease_timeout = _pool_settings() | |
| if size <= 0: | |
| return None | |
| # Check the effective backend before returning the cached singleton. This | |
| # matters when a long-lived process changes deployment configuration (and | |
| # in tests): a pool created while E2B was enabled must never leak back into | |
| # container/local mode, where /inputs and /outputs are worker mounts. | |
| if _sandbox_pool is not None: | |
| return _sandbox_pool | |
| with _sandbox_pool_lock: | |
| if _sandbox_pool is None: | |
| _sandbox_pool = SandboxPool( | |
| size=size, | |
| lease_timeout_s=lease_timeout, | |
| api_key=api_key, | |
| template=template, | |
| e2b_timeout=e2b_timeout, | |
| allow_bwrap_fallback=backend == "auto", | |
| ) | |
| return _sandbox_pool | |
| async def asandbox_write_file( | |
| sandbox: Any, path: str, content: str, *, mode: str = "w", | |
| ) -> tuple[bool, str]: | |
| """Async ``sandbox_write_file`` — never blocks the event loop.""" | |
| import asyncio | |
| return await asyncio.to_thread( | |
| sandbox_write_file, sandbox, path, content, mode=mode, | |
| ) | |