From 18f05d6ba83d39e641068ef4643ca12aaae96c7e Mon Sep 17 00:00:00 2001 From: Vladislav Sokolovskii Date: Fri, 29 May 2026 10:20:14 +0200 Subject: [PATCH] Add E2B terminal backend --- README.md | 2 +- agent/prompt_builder.py | 5 +- cli-config.yaml.example | 15 +- hermes_cli/config.py | 8 +- hermes_cli/doctor.py | 23 ++ hermes_cli/setup.py | 62 ++++- hermes_cli/status.py | 3 + hermes_cli/tips.py | 3 +- hermes_cli/web_server.py | 2 +- pyproject.toml | 1 + tests/agent/test_prompt_builder.py | 3 +- tests/hermes_cli/test_setup.py | 33 +++ tests/test_project_metadata.py | 2 +- tests/tools/test_command_guards.py | 4 + tests/tools/test_hardline_blocklist.py | 4 +- tests/tools/test_terminal_requirements.py | 26 ++ tools/approval.py | 6 +- tools/code_execution_tool.py | 5 +- tools/env_probe.py | 2 +- tools/environments/e2b.py | 258 ++++++++++++++++++ tools/file_operations.py | 4 +- tools/file_tools.py | 5 +- tools/lazy_deps.py | 1 + tools/skills_tool.py | 2 +- tools/terminal_tool.py | 41 ++- tools/tool_result_storage.py | 2 +- .../docs/reference/environment-variables.md | 6 +- website/docs/user-guide/configuration.md | 6 +- website/docs/user-guide/features/tools.md | 5 +- 29 files changed, 497 insertions(+), 42 deletions(-) create mode 100644 tools/environments/e2b.py diff --git a/README.md b/README.md index fa2795305059..f9229da2d263 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open A closed learning loopAgent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. Scheduled automationsBuilt-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. Delegates and parallelizesSpawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. -Runs anywhere, not just your laptopSix terminal backends — local, Docker, SSH, Singularity, Modal, and Daytona. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. +Runs anywhere, not just your laptopSeven terminal backends — local, Docker, SSH, Singularity, Modal, E2B, and Daytona. Cloud backends offer sandbox isolation and pause/resume persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. Research-readyBatch trajectory generation, trajectory compression for training the next generation of tool-calling models. diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0f9822804281..f73e58847026 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -644,7 +644,7 @@ def _strip_yaml_frontmatter(content: str) -> str: # runs. For these backends, host info (Windows/Linux/macOS, $HOME, cwd) is # misleading — the agent should only see the machine it can actually touch. _REMOTE_TERMINAL_BACKENDS = frozenset({ - "docker", "singularity", "modal", "daytona", "ssh", + "docker", "singularity", "modal", "daytona", "e2b", "ssh", "managed_modal", }) @@ -659,6 +659,7 @@ def _strip_yaml_frontmatter(content: str) -> str: "modal": "a Modal sandbox (Linux)", "managed_modal": "a managed Modal sandbox (Linux)", "daytona": "a Daytona workspace (Linux)", + "e2b": "an E2B sandbox (Linux)", "ssh": "a remote host reached over SSH (likely Linux)", } @@ -772,7 +773,7 @@ def build_environment_hints() -> str: and a Windows-only note that `terminal` shells out to bash, not PowerShell). - For **remote / sandbox** terminal backends (docker, singularity, - modal, daytona, ssh): host info is **suppressed** + modal, daytona, e2b, ssh): host info is **suppressed** because the agent's tools can't touch the host — only the backend matters. A live probe inside the backend reports its OS, user, $HOME, and cwd. Falls back to a static summary if the probe fails. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 355b6bb75694..de0bb718ccac 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -256,8 +256,21 @@ terminal: # daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # container_disk: 10240 # Daytona max is 10GB per sandbox +# ----------------------------------------------------------------------------- +# OPTION 7: E2B cloud execution +# Commands run in secure E2B cloud sandboxes +# Great for: Cloud code execution, isolated internet-enabled sandboxes +# Requires: pip install e2b, E2B_API_KEY env var +# ----------------------------------------------------------------------------- +# terminal: +# backend: "e2b" +# cwd: "~" +# timeout: 180 +# lifetime_seconds: 300 +# e2b_template: "base" + # -# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) --- +# --- Container resource limits (docker, singularity, modal, daytona, e2b -- ignored for local/ssh) --- # These settings apply to all container backends. They control the resources # allocated to the sandbox and whether its filesystem persists across sessions. container_cpu: 1 # CPU cores diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 52b7021d8b54..3718baa9907e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -778,7 +778,8 @@ def _ensure_hermes_home_managed(home: Path): "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", - # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh) + "e2b_template": "base", + # Container resource limits (docker, singularity, modal, daytona, e2b — ignored for local/ssh) "container_cpu": 1, "container_memory": 5120, # MB (default 5GB) "container_disk": 51200, # MB (default 50GB) @@ -5381,6 +5382,10 @@ def show_config(): print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") daytona_key = get_env_value('DAYTONA_API_KEY') print(f" API key: {'configured' if daytona_key else '(not set)'}") + elif terminal.get('backend') == 'e2b': + print(f" E2B template: {terminal.get('e2b_template', 'base')}") + e2b_key = get_env_value('E2B_API_KEY') + print(f" API key: {'configured' if e2b_key else '(not set)'}") elif terminal.get('backend') == 'ssh': ssh_host = get_env_value('TERMINAL_SSH_HOST') ssh_user = get_env_value('TERMINAL_SSH_USER') @@ -5577,6 +5582,7 @@ def set_config_value(key: str, value: str): "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", + "terminal.e2b_template": "TERMINAL_E2B_TEMPLATE", "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 3db70beaa72a..24f7332541c4 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1257,6 +1257,29 @@ def run_doctor(args): issues, ) + # E2B (if using e2b backend) + if terminal_env == "e2b": + e2b_key = os.getenv("E2B_API_KEY") + if e2b_key: + check_ok("E2B API key", "(configured)") + else: + _fail_and_issue( + "E2B_API_KEY not set", + "(required for TERMINAL_ENV=e2b)", + "Set E2B_API_KEY environment variable", + issues, + ) + try: + from e2b import Sandbox # noqa: F401 — SDK presence check + check_ok("e2b SDK", "(installed)") + except ImportError: + _fail_and_issue( + "e2b SDK not installed", + "(pip install e2b)", + "Install e2b SDK: pip install e2b", + issues, + ) + # Node.js + agent-browser (for browser automation tools) if _safe_which("node"): check_ok("Node.js") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index c9fec686b0eb..2482b8b7ca2b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -636,7 +636,7 @@ def _print_setup_summary(config: dict, hermes_home): def _prompt_container_resources(config: dict): - """Prompt for container resource settings (Docker, Singularity, Modal, Daytona).""" + """Prompt for container resource settings (Docker, Singularity, Modal, Daytona, E2B).""" terminal = config.setdefault("terminal", {}) print() @@ -1306,13 +1306,14 @@ def setup_terminal_backend(config: dict): "Local - run directly on this machine (default)", "Docker - isolated container with configurable resources", "Modal - serverless cloud sandbox", + "E2B - secure cloud sandbox", "SSH - run on a remote machine", "Daytona - persistent cloud development environment", ] - idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"} - backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4} + idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "e2b", 4: "ssh", 5: "daytona"} + backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "e2b": 3, "ssh": 4, "daytona": 5} - next_idx = 5 + next_idx = 6 if is_linux: terminal_choices.append("Singularity/Apptainer - HPC-friendly container") idx_to_backend[next_idx] = "singularity" @@ -1558,6 +1559,59 @@ def setup_terminal_backend(config: dict): _prompt_container_resources(config) + elif selected_backend == "e2b": + print_success("Terminal backend: E2B") + print_info("Secure cloud sandboxes with pause/resume persistence.") + print_info("Sign up at: https://e2b.dev") + + try: + __import__("e2b") + except ImportError: + print_info("Installing E2B SDK...") + import subprocess + + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "--python", sys.executable, "e2b"], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "e2b"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success("E2B SDK installed") + else: + print_warning("Install failed — run manually: pip install e2b") + if result.stderr: + print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") + + print() + existing_key = get_env_value("E2B_API_KEY") + if existing_key: + print_info(" E2B API key: already configured") + if prompt_yes_no(" Update API key?", False): + api_key = prompt(" E2B API key", password=True) + if api_key: + save_env_value("E2B_API_KEY", api_key) + print_success(" Updated") + else: + api_key = prompt(" E2B API key", password=True) + if api_key: + save_env_value("E2B_API_KEY", api_key) + print_success(" Configured") + + current_template = cfg_get(config, "terminal", "e2b_template", default="base") + template = prompt(" Sandbox template", current_template) + config["terminal"]["e2b_template"] = template or "base" + save_env_value("TERMINAL_E2B_TEMPLATE", config["terminal"]["e2b_template"]) + + _prompt_container_resources(config) + elif selected_backend == "ssh": print_success("Terminal backend: SSH") print_info("Run commands on a remote machine via SSH.") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index f1d2f5f9ff74..d5304d8d1992 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -413,6 +413,9 @@ def _resolve_env(env_ref) -> str: elif terminal_env == "daytona": daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20") print(f" Daytona Image: {daytona_image}") + elif terminal_env == "e2b": + e2b_template = os.getenv("TERMINAL_E2B_TEMPLATE", "base") + print(f" E2B Template: {e2b_template}") sudo_password = os.getenv("SUDO_PASSWORD", "") print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index feebe4310a09..f09ab594ba5b 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -147,7 +147,7 @@ "mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.", "Terminal commands support background mode with notify_on_complete for long-running tasks.", "Terminal background processes support watch_patterns to alert on specific output lines.", - "The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.", + "The terminal tool supports 7 backends: local, Docker, SSH, Modal, E2B, Daytona, and Singularity.", # --- Profiles --- "Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.", @@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str: """ return random.choice(TIPS) - diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index eaa1b2432d8b..ffd6066acb35 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -305,7 +305,7 @@ async def auth_middleware(request: Request, call_next): "terminal.backend": { "type": "select", "description": "Terminal execution backend", - "options": ["local", "docker", "ssh", "modal", "daytona", "singularity"], + "options": ["local", "docker", "ssh", "modal", "e2b", "daytona", "singularity"], }, "terminal.modal_mode": { "type": "select", diff --git a/pyproject.toml b/pyproject.toml index f2164724ee74..b31dc6afcd99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ fal = ["fal-client==0.13.1"] edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] +e2b = ["e2b==2.25.0"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index e0370c30905a..70ef3ccd851a 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -941,7 +941,7 @@ def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatc def test_remote_backend_list_covers_known_sandboxes(self): """Regression guard: if someone adds a remote backend, they must list it here.""" import agent.prompt_builder as _pb - for backend in ("docker", "singularity", "modal", "daytona", "ssh"): + for backend in ("docker", "singularity", "modal", "daytona", "e2b", "ssh"): assert backend in _pb._REMOTE_TERMINAL_BACKENDS, ( f"{backend!r} must be in _REMOTE_TERMINAL_BACKENDS so its host " f"info is suppressed in the system prompt" @@ -1193,4 +1193,3 @@ def test_guidance_is_string(self): # ========================================================================= - diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index abd26a0a3065..cf266407e5bf 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -479,6 +479,39 @@ def fake_prompt_choice(question, choices, default=0): assert config["terminal"]["modal_mode"] == "direct" +def test_e2b_setup_saves_api_key_and_template(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("E2B_API_KEY", raising=False) + config = load_config() + + def fake_prompt_choice(question, choices, default=0): + if question == "Select terminal backend:": + assert any(choice.startswith("E2B -") for choice in choices) + return 3 + raise AssertionError(f"Unexpected prompt_choice call: {question}") + + prompt_values = iter(["e2b_test_key", "base"]) + saved = {} + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values)) + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None) + monkeypatch.setattr("hermes_cli.setup.get_env_value", lambda key: "") + monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda key, value: saved.update({key: value})) + monkeypatch.setitem(sys.modules, "e2b", types.SimpleNamespace(Sandbox=object())) + + from hermes_cli.setup import setup_terminal_backend + + setup_terminal_backend(config) + + assert config["terminal"]["backend"] == "e2b" + assert config["terminal"]["e2b_template"] == "base" + assert saved["E2B_API_KEY"] == "e2b_test_key" + assert saved["TERMINAL_E2B_TEMPLATE"] == "base" + assert saved["TERMINAL_ENV"] == "e2b" + + def test_setup_slack_saves_home_channel(monkeypatch): """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one.""" saved = {} diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 45afb3c1aa42..013b16faa759 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -70,7 +70,7 @@ def test_lazy_installable_extras_excluded_from_all(): "fal", "edge-tts", "tts-premium", "voice", # faster-whisper / sounddevice / numpy - "modal", "daytona", + "modal", "daytona", "e2b", "messaging", "slack", "matrix", "dingtalk", "feishu", "honcho", "hindsight", } diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py index b9be68379718..c9bfff1bb0d0 100644 --- a/tests/tools/test_command_guards.py +++ b/tests/tools/test_command_guards.py @@ -73,6 +73,10 @@ def test_daytona_skips_both(self): result = check_all_command_guards("rm -rf /", "daytona") assert result["approved"] is True + def test_e2b_skips_both(self): + result = check_all_command_guards("rm -rf /", "e2b") + assert result["approved"] is True + # --------------------------------------------------------------------------- # tirith allow + safe command diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index 8d8062139b8a..2e63a47f0de9 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -239,7 +239,7 @@ def test_container_backends_still_bypass(clean_session): Hardline only protects environments with real host impact (local, ssh). """ - for env in ("docker", "singularity", "modal", "daytona"): + for env in ("docker", "singularity", "modal", "daytona", "e2b"): r1 = check_dangerous_command("rm -rf /", env) assert r1["approved"] is True, f"container {env} should still bypass" r2 = check_all_command_guards("rm -rf /", env) @@ -370,7 +370,7 @@ def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch): def test_sudo_stdin_guard_container_bypass(clean_session): """Containerized backends still bypass — they can't touch the host.""" - for env in ("docker", "singularity", "modal", "daytona"): + for env in ("docker", "singularity", "modal", "daytona", "e2b"): for cmd in _SUDO_STDIN_BLOCK: result = check_all_command_guards(cmd, env) assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}" diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index a2c1f00e12f2..447f8565f650 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -1,5 +1,7 @@ import importlib import logging +import sys +import types terminal_tool_module = importlib.import_module("tools.terminal_tool") @@ -22,6 +24,7 @@ def _clear_terminal_env(monkeypatch): "TERMINAL_TIMEOUT", "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", + "E2B_API_KEY", "HOME", "USERPROFILE", ] @@ -74,6 +77,29 @@ def test_ssh_backend_without_host_or_user_logs_and_returns_false(monkeypatch, ca ) +def test_e2b_backend_without_api_key_logs_and_returns_false(monkeypatch, caplog): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "e2b") + + with caplog.at_level(logging.ERROR): + ok = terminal_tool_module.check_terminal_requirements() + + assert ok is False + assert any( + "E2B backend selected but E2B_API_KEY is not set" in record.getMessage() + for record in caplog.records + ) + + +def test_e2b_backend_with_api_key_checks_sdk(monkeypatch): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "e2b") + monkeypatch.setenv("E2B_API_KEY", "e2b_test") + monkeypatch.setitem(sys.modules, "e2b", types.SimpleNamespace(Sandbox=object())) + + assert terminal_tool_module.check_terminal_requirements() is True + + def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, caplog, tmp_path): _clear_terminal_env(monkeypatch) monkeypatch.setenv("TERMINAL_ENV", "modal") diff --git a/tools/approval.py b/tools/approval.py index cc5aedc9e029..d6930da89dc0 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -171,7 +171,7 @@ def _is_gateway_approval_context() -> bool: # # Hardline only applies to environments that can actually damage the host # (local, ssh, container-host cron). Containerized backends (docker, -# singularity, modal, daytona) already bypass the dangerous-command layer +# singularity, modal, daytona, e2b) already bypass the dangerous-command layer # because nothing they do can touch the host, so we leave that behavior # alone. # @@ -937,7 +937,7 @@ def check_dangerous_command(command: str, env_type: str, Returns: {"approved": True/False, "message": str or None, ...} """ - if env_type in {"docker", "singularity", "modal", "daytona"}: + if env_type in {"docker", "singularity", "modal", "daytona", "e2b"}: return {"approved": True, "message": None} # Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd @@ -1067,7 +1067,7 @@ def check_all_command_guards(command: str, env_type: str, other was shown to the user. """ # Skip containers for both checks - if env_type in {"docker", "singularity", "modal", "daytona"}: + if env_type in {"docker", "singularity", "modal", "daytona", "e2b"}: return {"approved": True, "message": None} # Hardline floor: unconditional block for catastrophic commands diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 23c0434b660a..f45064c23fa8 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -590,18 +590,21 @@ def _get_or_create_env(task_id: str): image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "e2b": + image = overrides.get("e2b_template") or config["e2b_template"] else: image = "" cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in {"docker", "singularity", "modal", "daytona"}: + if env_type in {"docker", "singularity", "modal", "daytona", "e2b"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), + "lifetime_seconds": config.get("lifetime_seconds", 300), "docker_volumes": config.get("docker_volumes", []), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), } diff --git a/tools/env_probe.py b/tools/env_probe.py index dfb715a98711..9e74376292af 100644 --- a/tools/env_probe.py +++ b/tools/env_probe.py @@ -49,7 +49,7 @@ # Duplicated rather than imported to avoid a circular import (prompt_builder # imports nothing from tools). _REMOTE_BACKENDS = frozenset({ - "docker", "singularity", "modal", "daytona", "ssh", "managed_modal", + "docker", "singularity", "modal", "daytona", "e2b", "ssh", "managed_modal", }) diff --git a/tools/environments/e2b.py b/tools/environments/e2b.py new file mode 100644 index 000000000000..b037b8d11079 --- /dev/null +++ b/tools/environments/e2b.py @@ -0,0 +1,258 @@ +"""E2B cloud execution environment. + +Uses the E2B Python SDK to run commands in cloud sandboxes. Persistent mode +pauses sandboxes on cleanup and reconnects by sandbox id on the next creation. +""" + +import logging +import os +import shlex +import threading +from pathlib import Path + +from tools.environments.base import ( + BaseEnvironment, + _ThreadedProcessHandle, + _load_json_store, + _save_json_store, + get_sandbox_dir, +) +from tools.environments.file_sync import ( + FileSyncManager, + iter_sync_files, + quoted_mkdir_command, + quoted_rm_command, + unique_parent_dirs, +) + +logger = logging.getLogger(__name__) + + +class E2BEnvironment(BaseEnvironment): + """E2B cloud sandbox execution backend.""" + + _stdin_mode = "heredoc" + + def __init__( + self, + template: str = "base", + cwd: str = "~", + timeout: int = 60, + lifetime_seconds: int = 300, + persistent_filesystem: bool = True, + task_id: str = "default", + ): + requested_cwd = cwd + super().__init__(cwd=cwd, timeout=timeout) + + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("terminal.e2b", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + from e2b import Sandbox + from e2b.exceptions import SandboxException, SandboxNotFoundException + + self._Sandbox = Sandbox + self._SandboxException = SandboxException + self._SandboxNotFoundException = SandboxNotFoundException + self._persistent = persistent_filesystem + self._task_id = task_id + self._lifetime_seconds = lifetime_seconds + self._sandbox = None + self._lock = threading.Lock() + self._store_path = get_sandbox_dir() / "e2b" / "sandboxes.json" + + sandbox_id = self._load_sandbox_id() + if self._persistent and sandbox_id: + try: + self._sandbox = Sandbox.connect(sandbox_id, timeout=lifetime_seconds) + logger.info("E2B: reconnected sandbox %s for task %s", sandbox_id, task_id) + except SandboxNotFoundException: + logger.info("E2B: stored sandbox %s no longer exists", sandbox_id) + self._forget_sandbox_id() + except Exception as e: + logger.warning("E2B: failed to reconnect sandbox %s: %s", sandbox_id, e) + self._sandbox = None + + if self._sandbox is None: + metadata = {"hermes_task_id": task_id} + self._sandbox = Sandbox.create( + template=template or None, + timeout=lifetime_seconds, + metadata=metadata, + lifecycle={"on_timeout": "pause", "auto_resume": True} + if persistent_filesystem + else None, + ) + logger.info("E2B: created sandbox %s for task %s", self._sandbox.sandbox_id, task_id) + if self._persistent: + self._save_sandbox_id(self._sandbox.sandbox_id) + + self._remote_home = "/home/user" + try: + home = self._sandbox.commands.run("echo $HOME", timeout=timeout).stdout.strip() + if home: + self._remote_home = home + if requested_cwd in {"~", "/home/user", "/root"}: + self.cwd = home + except Exception: + pass + logger.info("E2B: resolved home to %s, cwd to %s", self._remote_home, self.cwd) + + self._sync_manager = FileSyncManager( + get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"), + upload_fn=self._e2b_upload, + delete_fn=self._e2b_delete, + bulk_upload_fn=self._e2b_bulk_upload, + bulk_download_fn=self._e2b_bulk_download, + ) + self._sync_manager.sync(force=True) + self.init_session() + + def _load_sandbox_id(self) -> str | None: + data = _load_json_store(self._store_path) + value = data.get(self._task_id) + return str(value) if value else None + + def _save_sandbox_id(self, sandbox_id: str) -> None: + data = _load_json_store(self._store_path) + data[self._task_id] = sandbox_id + _save_json_store(self._store_path, data) + + def _forget_sandbox_id(self) -> None: + data = _load_json_store(self._store_path) + if self._task_id in data: + data.pop(self._task_id, None) + _save_json_store(self._store_path, data) + + def _e2b_upload(self, host_path: str, remote_path: str) -> None: + with open(host_path, "rb") as handle: + self._sandbox.files.write(remote_path, handle) + + def _e2b_bulk_upload(self, files: list[tuple[str, str]]) -> None: + if not files: + return + + parents = unique_parent_dirs(files) + if parents: + self._sandbox.commands.run(quoted_mkdir_command(parents), timeout=self.timeout) + + for host_path, remote_path in files: + self._e2b_upload(host_path, remote_path) + + def _e2b_bulk_download(self, dest: Path) -> None: + rel_base = f"{self._remote_home}/.hermes".lstrip("/") + remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar" + self._sandbox.commands.run( + f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(rel_base)}", + timeout=self.timeout, + ) + data = self._sandbox.files.read(remote_tar, format="bytes") + dest.write_bytes(bytes(data)) + try: + self._sandbox.files.remove(remote_tar) + except Exception: + pass + + def _e2b_delete(self, remote_paths: list[str]) -> None: + self._sandbox.commands.run(quoted_rm_command(remote_paths), timeout=self.timeout) + + def _ensure_sandbox_ready(self) -> None: + if self._sandbox is None: + raise RuntimeError("E2B sandbox is not initialized") + try: + if not self._sandbox.is_running(): + self._sandbox = self._sandbox.connect(timeout=self._lifetime_seconds) + except Exception: + sandbox_id = getattr(self._sandbox, "sandbox_id", None) + if sandbox_id: + self._sandbox = self._Sandbox.connect(sandbox_id, timeout=self._lifetime_seconds) + else: + raise + + def _before_execute(self) -> None: + with self._lock: + self._ensure_sandbox_ready() + self._sync_manager.sync() + + def _run_bash( + self, + cmd_string: str, + *, + login: bool = False, + timeout: int = 120, + stdin_data: str | None = None, + ): + sandbox = self._sandbox + state = {"handle": None} + + def cancel(): + handle = state.get("handle") + if handle is not None: + try: + handle.kill() + except Exception: + pass + + def exec_fn() -> tuple[str, int]: + from e2b.sandbox.commands.command_handle import CommandExitException + + parts: list[str] = [] + try: + handle = sandbox.commands.run( + cmd_string, + background=True, + timeout=timeout, + stdin=stdin_data is not None, + ) + state["handle"] = handle + if stdin_data is not None: + sandbox.commands.send_stdin(handle.pid, stdin_data) + result = handle.wait( + on_stdout=parts.append, + on_stderr=parts.append, + ) + if not parts: + parts.extend([result.stdout or "", result.stderr or ""]) + return ("".join(parts), result.exit_code) + except CommandExitException as exc: + if not parts: + parts.extend([exc.stdout or "", exc.stderr or ""]) + return ("".join(parts), exc.exit_code) + except Exception as exc: + if parts: + return ("".join(parts) + f"\n[E2B command failed: {exc}]", 1) + return (f"E2B command failed: {exc}", 1) + + return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel) + + def cleanup(self): + with self._lock: + if self._sandbox is None: + return + + if self._sync_manager: + logger.info("E2B: syncing files from sandbox...") + try: + self._sync_manager.sync_back() + except Exception as e: + logger.warning("E2B: sync_back failed: %s", e) + + try: + if self._persistent: + self._sandbox.pause() + self._save_sandbox_id(self._sandbox.sandbox_id) + logger.info("E2B: paused sandbox %s", self._sandbox.sandbox_id) + else: + sandbox_id = self._sandbox.sandbox_id + self._sandbox.kill() + self._forget_sandbox_id() + logger.info("E2B: killed sandbox %s", sandbox_id) + except Exception as e: + logger.warning("E2B: cleanup failed: %s", e) + self._sandbox = None diff --git a/tools/file_operations.py b/tools/file_operations.py index e2f98278e6a5..1c5866842253 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -3,7 +3,7 @@ File Operations Module Provides file manipulation capabilities (read, write, patch, search) that work -across all terminal backends (local, docker, ssh, singularity, modal, daytona). +across all terminal backends (local, docker, ssh, singularity, modal, daytona, e2b). The key insight is that all file operations can be expressed as shell commands, so we wrap the terminal backend's execute() interface to provide a unified file API. @@ -583,7 +583,7 @@ class ShellFileOperations(FileOperations): File operations implemented via shell commands. Works with ANY terminal backend that has execute(command, cwd) method. - This includes local, docker, singularity, ssh, modal, and daytona environments. + This includes local, docker, singularity, ssh, modal, daytona, and e2b environments. """ def __init__(self, terminal_env, cwd: str = None): diff --git a/tools/file_tools.py b/tools/file_tools.py index 54a089fc9d0f..a8f1f471e38c 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -460,6 +460,8 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "e2b": + image = overrides.get("e2b_template") or config["e2b_template"] else: image = "" @@ -467,12 +469,13 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: logger.info("Creating new %s environment for task %s...", env_type, task_id[:8]) container_config = None - if env_type in {"docker", "singularity", "modal", "daytona"}: + if env_type in {"docker", "singularity", "modal", "daytona", "e2b"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), + "lifetime_seconds": config.get("lifetime_seconds", 300), "docker_volumes": config.get("docker_volumes", []), "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), "docker_forward_env": config.get("docker_forward_env", []), diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 393397349d81..8f35662177e1 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -156,6 +156,7 @@ # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), + "terminal.e2b": ("e2b==2.25.0",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": ( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 054be4cae3d3..118b69a7c22e 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -103,7 +103,7 @@ } _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _REMOTE_ENV_BACKENDS = frozenset( - {"docker", "singularity", "modal", "ssh", "daytona"} + {"docker", "singularity", "modal", "ssh", "daytona", "e2b"} ) _secret_capture_callback = None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 8351d61eb93d..0c97163e6b62 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2,7 +2,7 @@ """ Terminal Tool Module -A terminal tool that executes commands in local, Docker, Modal, SSH, +A terminal tool that executes commands in local, Docker, Modal, E2B, SSH, Singularity, and Daytona environments. Supports local execution, containerized backends, and cloud sandboxes, including managed Modal mode. @@ -1027,7 +1027,7 @@ def _get_env_config() -> Dict[str, Any]: # root-like cwd. if env_type == "local": default_cwd = os.getcwd() - elif env_type == "ssh": + elif env_type in {"ssh", "e2b"}: default_cwd = "~" else: default_cwd = "/root" @@ -1050,7 +1050,7 @@ def _get_env_config() -> Dict[str, Any]: ): host_cwd = candidate cwd = "/workspace" - elif env_type in {"modal", "docker", "singularity", "daytona"} and cwd: + elif env_type in {"modal", "docker", "singularity", "daytona", "e2b"} and cwd: # Host paths and relative paths that won't work inside containers is_host_path = any(cwd.startswith(p) for p in host_prefixes) is_relative = not os.path.isabs(cwd) # e.g. "." or "src/" @@ -1068,6 +1068,7 @@ def _get_env_config() -> Dict[str, Any]: "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), + "e2b_template": os.getenv("TERMINAL_E2B_TEMPLATE", "base"), "cwd": cwd, "host_cwd": host_cwd, "docker_mount_cwd_to_workspace": mount_docker_cwd, @@ -1087,7 +1088,7 @@ def _get_env_config() -> Dict[str, Any]: ).lower() in {"true", "1", "yes"}, "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"}, # Container resource config (applies to docker, singularity, modal, - # daytona -- ignored for local/ssh) + # daytona/e2b -- ignored for local/ssh) "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"), "container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB) "container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB) @@ -1134,7 +1135,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, Args: env_type: One of "local", "docker", "singularity", "modal", - "daytona", "ssh" + "daytona", "e2b", "ssh" image: Docker/Singularity/Modal image name (ignored for local/ssh) cwd: Working directory timeout: Default command timeout @@ -1255,6 +1256,14 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, persistent_filesystem=persistent, task_id=task_id, ) + elif env_type == "e2b": + from tools.environments.e2b import E2BEnvironment as _E2BEnvironment + return _E2BEnvironment( + template=image, cwd=cwd, timeout=timeout, + lifetime_seconds=cc.get("lifetime_seconds", 300), + persistent_filesystem=persistent, task_id=task_id, + ) + elif env_type == "ssh": if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"): raise ValueError("SSH environment requires ssh_host and ssh_user to be configured") @@ -1270,7 +1279,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, else: raise ValueError( f"Unknown environment type: {env_type}. Use 'local', 'docker', " - f"'singularity', 'modal', 'daytona', or 'ssh'" + f"'singularity', 'modal', 'daytona', 'e2b', or 'ssh'" ) @@ -1795,6 +1804,8 @@ def terminal_tool( image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "e2b": + image = overrides.get("e2b_template") or config["e2b_template"] else: image = "" @@ -1871,12 +1882,13 @@ def terminal_tool( } container_config = None - if env_type in {"docker", "singularity", "modal", "daytona"}: + if env_type in {"docker", "singularity", "modal", "daytona", "e2b"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), + "lifetime_seconds": config.get("lifetime_seconds", 300), "modal_mode": config.get("modal_mode", "auto"), "docker_volumes": config.get("docker_volumes", []), "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), @@ -2416,10 +2428,20 @@ def check_terminal_requirements() -> bool: from daytona import Daytona # noqa: F401 — SDK presence check return os.getenv("DAYTONA_API_KEY") is not None + elif env_type == "e2b": + if not os.getenv("E2B_API_KEY"): + logger.error( + "E2B backend selected but E2B_API_KEY is not set. " + "Configure E2B or choose a different TERMINAL_ENV." + ) + return False + from e2b import Sandbox # noqa: F401 — SDK presence check + return True + else: logger.error( "Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, " - "modal, daytona, ssh.", + "modal, daytona, e2b, ssh.", env_type, ) return False @@ -2462,12 +2484,13 @@ def check_terminal_requirements() -> bool: print( " TERMINAL_ENV: " f"{os.getenv('TERMINAL_ENV', 'local')} " - "(local/docker/singularity/modal/daytona/ssh)" + "(local/docker/singularity/modal/daytona/e2b/ssh)" ) print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}") print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}") print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}") print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}") + print(f" TERMINAL_E2B_TEMPLATE: {os.getenv('TERMINAL_E2B_TEMPLATE', 'base')}") print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}") from hermes_constants import display_hermes_home as _dhh print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}") diff --git a/tools/tool_result_storage.py b/tools/tool_result_storage.py index fed8621eee41..398af045edff 100644 --- a/tools/tool_result_storage.py +++ b/tools/tool_result_storage.py @@ -130,7 +130,7 @@ def maybe_persist_tool_result( """Layer 2: persist oversized result into the sandbox, return preview + path. Writes via env.execute() so the file is accessible from any backend - (local, Docker, SSH, Modal, Daytona). Falls back to inline truncation + (local, Docker, SSH, Modal, Daytona, E2B). Falls back to inline truncation if write fails or no env is available. Args: diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 391e067dca36..a49a22c5d4b8 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -154,6 +154,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `HINDSIGHT_TIMEOUT` | Timeout in seconds for Hindsight memory-provider API calls (default: `60`). Bump this if your Hindsight instance is slow to respond during `/sync` or `on_session_switch` and you're seeing timeouts in `errors.log`. | | `SUPERMEMORY_API_KEY` | Semantic long-term memory with profile recall and session ingest ([supermemory.ai](https://supermemory.ai)) | | `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) | +| `E2B_API_KEY` | E2B cloud sandboxes ([e2b.dev](https://e2b.dev/)) | ### Langfuse Observability @@ -186,7 +187,7 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway) | Variable | Description | |----------|-------------| -| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` | +| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `e2b`, `daytona` | | `HERMES_DOCKER_BINARY` | Override the container binary Hermes shells out to (e.g. `podman`, `/usr/local/bin/docker`). When unset, Hermes auto-discovers `docker` or `podman` on `PATH`. Needed when both are installed and you want the non-default, or when the binary lives outside `PATH`. | | `TERMINAL_DOCKER_IMAGE` | Docker image (default: `nikolaik/python-nodejs:python3.11-nodejs20`) | | `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. | @@ -195,6 +196,7 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway) | `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | | `TERMINAL_MODAL_IMAGE` | Modal container image | | `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image | +| `TERMINAL_E2B_TEMPLATE` | E2B sandbox template | | `TERMINAL_TIMEOUT` | Command timeout in seconds | | `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds | | `TERMINAL_CWD` | Working directory for terminal sessions (gateway/cron only; CLI uses launch dir) | @@ -212,7 +214,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TERMINAL_SSH_KEY` | Path to private key | | `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH (default: follows `TERMINAL_PERSISTENT_SHELL`) | -## Container Resources (Docker, Singularity, Modal, Daytona) +## Container Resources (Docker, Singularity, Modal, E2B, Daytona) | Variable | Description | |----------|-------------| diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ecdf6e66ccde..18e1e7ccc611 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -87,16 +87,17 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM ## Terminal Backend Configuration -Hermes supports six terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, or a Singularity/Apptainer container. +Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), an E2B sandbox, a Daytona workspace, or a Singularity/Apptainer container. ```yaml terminal: - backend: local # local | docker | ssh | modal | daytona | singularity + backend: local # local | docker | ssh | modal | e2b | daytona | singularity cwd: "." # Gateway/cron working directory (CLI always uses launch dir) timeout: 180 # Per-command timeout in seconds env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code) singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Modal backend + e2b_template: "base" # Sandbox template for E2B backend daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend ``` @@ -110,6 +111,7 @@ For cloud sandboxes such as Modal and Daytona, `container_persistent: true` mean | **docker** | Single persistent Docker container (shared across session, `/new`, subagents) | Full (namespaces, cap-drop) | Safe sandboxing, CI/CD | | **ssh** | Remote server via SSH | Network boundary | Remote dev, powerful hardware | | **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | +| **e2b** | E2B sandbox | Full (cloud container) | Secure cloud code execution | | **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | | **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index c4ff6046713a..e4948eddc727 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -64,6 +64,7 @@ The terminal tool can execute commands in different environments: | `ssh` | Remote server | Sandboxing, keep agent away from its own code | | `singularity` | HPC containers | Cluster computing, rootless | | `modal` | Cloud execution | Serverless, scale | +| `e2b` | Cloud sandbox | Secure code execution | | `daytona` | Cloud sandbox workspace | Persistent remote dev environments | ### Configuration @@ -71,7 +72,7 @@ The terminal tool can execute commands in different environments: ```yaml # In ~/.hermes/config.yaml terminal: - backend: local # or: docker, ssh, singularity, modal, daytona + backend: local # or: docker, ssh, singularity, modal, e2b, daytona cwd: "." # Working directory timeout: 180 # Command timeout in seconds ``` @@ -128,7 +129,7 @@ Configure CPU, memory, disk, and persistence for all container backends: ```yaml terminal: - backend: docker # or singularity, modal, daytona + backend: docker # or singularity, modal, e2b, daytona container_cpu: 1 # CPU cores (default: 1) container_memory: 5120 # Memory in MB (default: 5GB) container_disk: 51200 # Disk in MB (default: 50GB)